From eb19da1aa1964129e3f8be4e0255e8574ebc18f5 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 3 Feb 2026 18:49:37 +0100 Subject: [PATCH 1/3] chore: plugin manifest definition chore: make plugin manifest required All plugins must now declare a static manifest property with their metadata and resource requirements. This simplifies the manifest loader and provides better error messages. --- docs/docs/api/appkit/Class.Plugin.md | 43 ++++- .../api/appkit/Enumeration.ResourceType.md | 73 ++++++++ .../docs/api/appkit/Interface.ConfigSchema.md | 52 ++++++ .../appkit/Interface.ConfigSchemaProperty.md | 83 +++++++++ .../api/appkit/Interface.PluginManifest.md | 124 +++++++++++++ .../api/appkit/Interface.ResourceEntry.md | 123 +++++++++++++ .../appkit/Interface.ResourceRequirement.md | 69 +++++++ .../api/appkit/Interface.ValidationResult.md | 33 ++++ .../appkit/TypeAlias.ResourcePermission.md | 8 + docs/docs/api/appkit/index.md | 15 +- docs/docs/api/appkit/typedoc-sidebar.ts | 46 +++++ .../appkit/src/core/tests/databricks.test.ts | 26 +++ packages/appkit/src/index.ts | 11 ++ packages/appkit/src/plugin/plugin.ts | 47 ++++- .../appkit/src/plugins/analytics/analytics.ts | 4 + .../appkit/src/plugins/analytics/index.ts | 1 + .../appkit/src/plugins/analytics/manifest.ts | 49 +++++ packages/appkit/src/plugins/server/index.ts | 8 + .../appkit/src/plugins/server/manifest.ts | 47 +++++ .../server/tests/server.integration.test.ts | 10 + packages/appkit/src/registry/index.ts | 14 ++ packages/appkit/src/registry/types.ts | 174 ++++++++++++++++++ packages/shared/src/plugin.ts | 53 ++++++ 23 files changed, 1110 insertions(+), 3 deletions(-) create mode 100644 docs/docs/api/appkit/Enumeration.ResourceType.md create mode 100644 docs/docs/api/appkit/Interface.ConfigSchema.md create mode 100644 docs/docs/api/appkit/Interface.ConfigSchemaProperty.md create mode 100644 docs/docs/api/appkit/Interface.PluginManifest.md create mode 100644 docs/docs/api/appkit/Interface.ResourceEntry.md create mode 100644 docs/docs/api/appkit/Interface.ResourceRequirement.md create mode 100644 docs/docs/api/appkit/Interface.ValidationResult.md create mode 100644 docs/docs/api/appkit/TypeAlias.ResourcePermission.md create mode 100644 packages/appkit/src/plugins/analytics/manifest.ts create mode 100644 packages/appkit/src/plugins/server/manifest.ts create mode 100644 packages/appkit/src/registry/index.ts create mode 100644 packages/appkit/src/registry/types.ts diff --git a/docs/docs/api/appkit/Class.Plugin.md b/docs/docs/api/appkit/Class.Plugin.md index a741189e..19fd0e9e 100644 --- a/docs/docs/api/appkit/Class.Plugin.md +++ b/docs/docs/api/appkit/Class.Plugin.md @@ -1,6 +1,40 @@ # Abstract Class: Plugin\ -Base abstract class for creating AppKit plugins +Base abstract class for creating AppKit plugins. + +Plugins can optionally declare their resource requirements through: +1. A static `manifest` property - recommended for all plugins +2. A static `getResourceRequirements()` method - for dynamic requirements + +## Example + +```typescript +import { Plugin, toPlugin, PluginManifest, ResourceType } from '@databricks/appkit'; + +// Define manifest +const myManifest: PluginManifest = { + name: 'myPlugin', + displayName: 'My Plugin', + description: 'Does something awesome', + resources: { + required: [ + { + type: ResourceType.SQL_WAREHOUSE, + alias: 'warehouse', + description: 'SQL Warehouse for queries', + permission: 'CAN_USE', + env: 'DATABRICKS_WAREHOUSE_ID' + } + ], + optional: [] + } +}; + +class MyPlugin extends Plugin { + static manifest = myManifest; + // ... implementation +} +``` ## Type Parameters @@ -86,6 +120,8 @@ protected isReady: boolean = false; name: string; ``` +Plugin name identifier. + #### Implementation of ```ts @@ -116,6 +152,11 @@ protected telemetry: ITelemetry; static phase: PluginPhase = "normal"; ``` +Plugin initialization phase. +- 'core': Initialized first (e.g., config plugins) +- 'normal': Initialized second (most plugins) +- 'deferred': Initialized last (e.g., server plugin) + ## Methods ### abortActiveOperations() diff --git a/docs/docs/api/appkit/Enumeration.ResourceType.md b/docs/docs/api/appkit/Enumeration.ResourceType.md new file mode 100644 index 00000000..53241e47 --- /dev/null +++ b/docs/docs/api/appkit/Enumeration.ResourceType.md @@ -0,0 +1,73 @@ +# Enumeration: ResourceType + +Supported Databricks resource types that plugins can depend on. + +## Enumeration Members + +### JOB + +```ts +JOB: "job"; +``` + +Databricks Job for scheduled or triggered workflows + +*** + +### LAKEBASE + +```ts +LAKEBASE: "lakebase"; +``` + +Lakebase instance for persistent caching or data storage + +*** + +### SECRET\_SCOPE + +```ts +SECRET_SCOPE: "secret-scope"; +``` + +Secret scope for secure credential storage + +*** + +### SERVING\_ENDPOINT + +```ts +SERVING_ENDPOINT: "serving-endpoint"; +``` + +Model serving endpoint for ML inference + +*** + +### SQL\_WAREHOUSE + +```ts +SQL_WAREHOUSE: "sql-warehouse"; +``` + +Databricks SQL Warehouse for query execution + +*** + +### UNITY\_CATALOG + +```ts +UNITY_CATALOG: "unity-catalog"; +``` + +Unity Catalog for data governance and metadata + +*** + +### VECTOR\_SEARCH\_INDEX + +```ts +VECTOR_SEARCH_INDEX: "vector-search-index"; +``` + +Vector search index for similarity search diff --git a/docs/docs/api/appkit/Interface.ConfigSchema.md b/docs/docs/api/appkit/Interface.ConfigSchema.md new file mode 100644 index 00000000..5ff1c797 --- /dev/null +++ b/docs/docs/api/appkit/Interface.ConfigSchema.md @@ -0,0 +1,52 @@ +# Interface: ConfigSchema + +Configuration schema definition for plugin config. +Uses JSON Schema format for validation and documentation. + +## Indexable + +```ts +[key: string]: unknown +``` + +Allow additional JSON Schema properties + +## Properties + +### additionalProperties? + +```ts +optional additionalProperties: boolean; +``` + +*** + +### items? + +```ts +optional items: ConfigSchema; +``` + +*** + +### properties? + +```ts +optional properties: Record; +``` + +*** + +### required? + +```ts +optional required: string[]; +``` + +*** + +### type + +```ts +type: "string" | "number" | "boolean" | "object" | "array"; +``` diff --git a/docs/docs/api/appkit/Interface.ConfigSchemaProperty.md b/docs/docs/api/appkit/Interface.ConfigSchemaProperty.md new file mode 100644 index 00000000..c8fd10cd --- /dev/null +++ b/docs/docs/api/appkit/Interface.ConfigSchemaProperty.md @@ -0,0 +1,83 @@ +# Interface: ConfigSchemaProperty + +Individual property definition in a config schema. + +## Properties + +### default? + +```ts +optional default: unknown; +``` + +*** + +### description? + +```ts +optional description: string; +``` + +*** + +### enum? + +```ts +optional enum: unknown[]; +``` + +*** + +### items? + +```ts +optional items: ConfigSchemaProperty; +``` + +*** + +### maximum? + +```ts +optional maximum: number; +``` + +*** + +### maxLength? + +```ts +optional maxLength: number; +``` + +*** + +### minimum? + +```ts +optional minimum: number; +``` + +*** + +### minLength? + +```ts +optional minLength: number; +``` + +*** + +### properties? + +```ts +optional properties: Record; +``` + +*** + +### type + +```ts +type: "string" | "number" | "boolean" | "object" | "array"; +``` diff --git a/docs/docs/api/appkit/Interface.PluginManifest.md b/docs/docs/api/appkit/Interface.PluginManifest.md new file mode 100644 index 00000000..00251a9f --- /dev/null +++ b/docs/docs/api/appkit/Interface.PluginManifest.md @@ -0,0 +1,124 @@ +# Interface: PluginManifest + +Plugin manifest that declares metadata and resource requirements. +Attached to plugin classes as a static property. + +## Properties + +### author? + +```ts +optional author: string; +``` + +Optional metadata for community plugins + +*** + +### config? + +```ts +optional config: { + schema: ConfigSchema; +}; +``` + +Configuration schema for the plugin. +Defines the shape and validation rules for plugin config. + +#### schema + +```ts +schema: ConfigSchema; +``` + +*** + +### description + +```ts +description: string; +``` + +Brief description of what the plugin does + +*** + +### displayName + +```ts +displayName: string; +``` + +Human-readable display name for UI/CLI + +*** + +### keywords? + +```ts +optional keywords: string[]; +``` + +*** + +### license? + +```ts +optional license: string; +``` + +*** + +### name + +```ts +name: string; +``` + +Plugin identifier (matches plugin.name) + +*** + +### repository? + +```ts +optional repository: string; +``` + +*** + +### resources + +```ts +resources: { + optional: Omit[]; + required: Omit[]; +}; +``` + +Resource requirements declaration + +#### optional + +```ts +optional: Omit[]; +``` + +Resources that enhance functionality but are not mandatory + +#### required + +```ts +required: Omit[]; +``` + +Resources that must be available for the plugin to function + +*** + +### version? + +```ts +optional version: string; +``` diff --git a/docs/docs/api/appkit/Interface.ResourceEntry.md b/docs/docs/api/appkit/Interface.ResourceEntry.md new file mode 100644 index 00000000..5d962219 --- /dev/null +++ b/docs/docs/api/appkit/Interface.ResourceEntry.md @@ -0,0 +1,123 @@ +# Interface: ResourceEntry + +Internal representation of a resource in the registry. +Extends ResourceRequirement with resolution state and plugin ownership. + +## Extends + +- [`ResourceRequirement`](Interface.ResourceRequirement.md) + +## Properties + +### alias + +```ts +alias: string; +``` + +Unique alias for this resource within the plugin (e.g., 'warehouse', 'secrets') + +#### Inherited from + +[`ResourceRequirement`](Interface.ResourceRequirement.md).[`alias`](Interface.ResourceRequirement.md#alias) + +*** + +### description + +```ts +description: string; +``` + +Human-readable description of why this resource is needed + +#### Inherited from + +[`ResourceRequirement`](Interface.ResourceRequirement.md).[`description`](Interface.ResourceRequirement.md#description) + +*** + +### env? + +```ts +optional env: string; +``` + +Environment variable name where the resource ID/value should be provided +Example: 'DATABRICKS_WAREHOUSE_ID', 'DATABRICKS_SECRET_SCOPE' + +#### Inherited from + +[`ResourceRequirement`](Interface.ResourceRequirement.md).[`env`](Interface.ResourceRequirement.md#env) + +*** + +### permission + +```ts +permission: ResourcePermission; +``` + +Required permission level for the resource + +#### Inherited from + +[`ResourceRequirement`](Interface.ResourceRequirement.md).[`permission`](Interface.ResourceRequirement.md#permission) + +*** + +### plugin + +```ts +plugin: string; +``` + +Plugin(s) that require this resource (comma-separated if multiple) + +*** + +### required + +```ts +required: boolean; +``` + +Whether this resource is required (true) or optional (false) + +#### Inherited from + +[`ResourceRequirement`](Interface.ResourceRequirement.md).[`required`](Interface.ResourceRequirement.md#required) + +*** + +### resolved + +```ts +resolved: boolean; +``` + +Whether the resource has been resolved (environment variable found) + +*** + +### type + +```ts +type: ResourceType; +``` + +Type of Databricks resource required + +#### Inherited from + +[`ResourceRequirement`](Interface.ResourceRequirement.md).[`type`](Interface.ResourceRequirement.md#type) + +*** + +### value? + +```ts +optional value: string; +``` + +The actual value of the resource (if resolved) diff --git a/docs/docs/api/appkit/Interface.ResourceRequirement.md b/docs/docs/api/appkit/Interface.ResourceRequirement.md new file mode 100644 index 00000000..86893314 --- /dev/null +++ b/docs/docs/api/appkit/Interface.ResourceRequirement.md @@ -0,0 +1,69 @@ +# Interface: ResourceRequirement + +Declares a resource requirement for a plugin. +Can be defined statically in a manifest or dynamically via getResourceRequirements(). + +## Extended by + +- [`ResourceEntry`](Interface.ResourceEntry.md) + +## Properties + +### alias + +```ts +alias: string; +``` + +Unique alias for this resource within the plugin (e.g., 'warehouse', 'secrets') + +*** + +### description + +```ts +description: string; +``` + +Human-readable description of why this resource is needed + +*** + +### env? + +```ts +optional env: string; +``` + +Environment variable name where the resource ID/value should be provided +Example: 'DATABRICKS_WAREHOUSE_ID', 'DATABRICKS_SECRET_SCOPE' + +*** + +### permission + +```ts +permission: ResourcePermission; +``` + +Required permission level for the resource + +*** + +### required + +```ts +required: boolean; +``` + +Whether this resource is required (true) or optional (false) + +*** + +### type + +```ts +type: ResourceType; +``` + +Type of Databricks resource required diff --git a/docs/docs/api/appkit/Interface.ValidationResult.md b/docs/docs/api/appkit/Interface.ValidationResult.md new file mode 100644 index 00000000..f71a4bce --- /dev/null +++ b/docs/docs/api/appkit/Interface.ValidationResult.md @@ -0,0 +1,33 @@ +# Interface: ValidationResult + +Result of validating all registered resources against the environment. + +## Properties + +### all + +```ts +all: ResourceEntry[]; +``` + +Complete list of all registered resources (required and optional) + +*** + +### missing + +```ts +missing: ResourceEntry[]; +``` + +List of missing required resources + +*** + +### valid + +```ts +valid: boolean; +``` + +Whether all required resources are available diff --git a/docs/docs/api/appkit/TypeAlias.ResourcePermission.md b/docs/docs/api/appkit/TypeAlias.ResourcePermission.md new file mode 100644 index 00000000..eb91fd57 --- /dev/null +++ b/docs/docs/api/appkit/TypeAlias.ResourcePermission.md @@ -0,0 +1,8 @@ +# Type Alias: ResourcePermission + +```ts +type ResourcePermission = "CAN_USE" | "CAN_MANAGE" | "CAN_VIEW" | "READ" | "WRITE" | "EXECUTE"; +``` + +Permission levels that can be required for a resource. +Based on Databricks permission model. diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index 772f3db3..b3fd4ccd 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -3,6 +3,12 @@ Core library for building Databricks applications with type-safe SQL queries, plugin architecture, and React integration. +## Enumerations + +| Enumeration | Description | +| ------ | ------ | +| [ResourceType](Enumeration.ResourceType.md) | Supported Databricks resource types that plugins can depend on. | + ## Classes | Class | Description | @@ -13,7 +19,7 @@ plugin architecture, and React integration. | [ConnectionError](Class.ConnectionError.md) | Error thrown when a connection or network operation fails. Use for database pool errors, API failures, timeouts, etc. | | [ExecutionError](Class.ExecutionError.md) | Error thrown when an operation execution fails. Use for statement failures, canceled operations, or unexpected states. | | [InitializationError](Class.InitializationError.md) | Error thrown when a service or component is not properly initialized. Use when accessing services before they are ready. | -| [Plugin](Class.Plugin.md) | Base abstract class for creating AppKit plugins | +| [Plugin](Class.Plugin.md) | Base abstract class for creating AppKit plugins. | | [ServerError](Class.ServerError.md) | Error thrown when server lifecycle operations fail. Use for server start/stop issues, configuration conflicts, etc. | | [TunnelError](Class.TunnelError.md) | Error thrown when remote tunnel operations fail. Use for tunnel connection issues, message parsing failures, etc. | | [ValidationError](Class.ValidationError.md) | Error thrown when input validation fails. Use for invalid parameters, missing required fields, or type mismatches. | @@ -24,15 +30,22 @@ plugin architecture, and React integration. | ------ | ------ | | [BasePluginConfig](Interface.BasePluginConfig.md) | Base configuration interface for AppKit plugins | | [CacheConfig](Interface.CacheConfig.md) | Configuration for caching | +| [ConfigSchema](Interface.ConfigSchema.md) | Configuration schema definition for plugin config. Uses JSON Schema format for validation and documentation. | +| [ConfigSchemaProperty](Interface.ConfigSchemaProperty.md) | Individual property definition in a config schema. | | [ITelemetry](Interface.ITelemetry.md) | Plugin-facing interface for OpenTelemetry instrumentation. Provides a thin abstraction over OpenTelemetry APIs for plugins. | +| [PluginManifest](Interface.PluginManifest.md) | Plugin manifest that declares metadata and resource requirements. Attached to plugin classes as a static property. | +| [ResourceEntry](Interface.ResourceEntry.md) | Internal representation of a resource in the registry. Extends ResourceRequirement with resolution state and plugin ownership. | +| [ResourceRequirement](Interface.ResourceRequirement.md) | Declares a resource requirement for a plugin. Can be defined statically in a manifest or dynamically via getResourceRequirements(). | | [StreamExecutionSettings](Interface.StreamExecutionSettings.md) | Configuration for streaming execution with default and user-scoped settings | | [TelemetryConfig](Interface.TelemetryConfig.md) | OpenTelemetry configuration for AppKit applications | +| [ValidationResult](Interface.ValidationResult.md) | Result of validating all registered resources against the environment. | ## Type Aliases | Type Alias | Description | | ------ | ------ | | [IAppRouter](TypeAlias.IAppRouter.md) | Express router type for plugin route registration | +| [ResourcePermission](TypeAlias.ResourcePermission.md) | Permission levels that can be required for a resource. Based on Databricks permission model. | ## Variables diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index 8fd695d5..93381150 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -1,6 +1,17 @@ import { SidebarsConfig } from "@docusaurus/plugin-content-docs"; const typedocSidebar: SidebarsConfig = { items: [ + { + type: "category", + label: "Enumerations", + items: [ + { + type: "doc", + id: "api/appkit/Enumeration.ResourceType", + label: "ResourceType" + } + ] + }, { type: "category", label: "Classes", @@ -71,11 +82,36 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Interface.CacheConfig", label: "CacheConfig" }, + { + type: "doc", + id: "api/appkit/Interface.ConfigSchema", + label: "ConfigSchema" + }, + { + type: "doc", + id: "api/appkit/Interface.ConfigSchemaProperty", + label: "ConfigSchemaProperty" + }, { type: "doc", id: "api/appkit/Interface.ITelemetry", label: "ITelemetry" }, + { + type: "doc", + id: "api/appkit/Interface.PluginManifest", + label: "PluginManifest" + }, + { + type: "doc", + id: "api/appkit/Interface.ResourceEntry", + label: "ResourceEntry" + }, + { + type: "doc", + id: "api/appkit/Interface.ResourceRequirement", + label: "ResourceRequirement" + }, { type: "doc", id: "api/appkit/Interface.StreamExecutionSettings", @@ -85,6 +121,11 @@ const typedocSidebar: SidebarsConfig = { type: "doc", id: "api/appkit/Interface.TelemetryConfig", label: "TelemetryConfig" + }, + { + type: "doc", + id: "api/appkit/Interface.ValidationResult", + label: "ValidationResult" } ] }, @@ -96,6 +137,11 @@ const typedocSidebar: SidebarsConfig = { type: "doc", id: "api/appkit/TypeAlias.IAppRouter", label: "IAppRouter" + }, + { + type: "doc", + id: "api/appkit/TypeAlias.ResourcePermission", + label: "ResourcePermission" } ] }, diff --git a/packages/appkit/src/core/tests/databricks.test.ts b/packages/appkit/src/core/tests/databricks.test.ts index bc2df7a4..381b5136 100644 --- a/packages/appkit/src/core/tests/databricks.test.ts +++ b/packages/appkit/src/core/tests/databricks.test.ts @@ -4,6 +4,19 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { ServiceContext } from "../../context/service-context"; import { AppKit, createApp } from "../appkit"; +// Helper function to create test manifests +function createTestManifest(name: string, displayName: string) { + return { + name, + displayName, + description: `Test plugin for ${name}`, + resources: { + required: [], + optional: [], + }, + }; +} + // Mock environment validation vi.mock("../utils", () => ({ validateEnv: vi.fn(), @@ -32,6 +45,7 @@ vi.mock("@databricks-apps/cache", () => ({ class CoreTestPlugin implements BasePlugin { static DEFAULT_CONFIG = { coreDefault: "core-value" }; static phase = "core" as const; + static manifest = createTestManifest("coreTest", "Core Test Plugin"); name = "coreTest"; setupCalled = false; validateEnvCalled = false; @@ -68,6 +82,7 @@ class CoreTestPlugin implements BasePlugin { class NormalTestPlugin implements BasePlugin { static DEFAULT_CONFIG = { normalDefault: "normal-value" }; static phase = "normal" as const; + static manifest = createTestManifest("normalTest", "Normal Test Plugin"); name = "normalTest"; setupCalled = false; validateEnvCalled = false; @@ -103,6 +118,7 @@ class NormalTestPlugin implements BasePlugin { class DeferredTestPlugin implements BasePlugin { static DEFAULT_CONFIG = { deferredDefault: "deferred-value" }; static phase = "deferred" as const; + static manifest = createTestManifest("deferredTest", "Deferred Test Plugin"); name = "deferredTest"; setupCalled = false; validateEnvCalled = false; @@ -140,6 +156,7 @@ class DeferredTestPlugin implements BasePlugin { class SlowSetupPlugin implements BasePlugin { static DEFAULT_CONFIG = {}; + static manifest = createTestManifest("slowSetup", "Slow Setup Plugin"); name = "slowSetup"; setupDelay: number; setupCalled = false; @@ -170,6 +187,7 @@ class SlowSetupPlugin implements BasePlugin { class FailingPlugin implements BasePlugin { static DEFAULT_CONFIG = {}; + static manifest = createTestManifest("failing", "Failing Plugin"); name = "failing"; validateEnv() { @@ -527,6 +545,10 @@ describe("AppKit", () => { test("should bind SDK methods to plugin instance", async () => { class ContextTestPlugin implements BasePlugin { static DEFAULT_CONFIG = {}; + static manifest = createTestManifest( + "contextTest", + "Context Test Plugin", + ); name = "contextTest"; private counter = 0; @@ -567,6 +589,10 @@ describe("AppKit", () => { test("should maintain context when SDK method is passed as callback", async () => { class CallbackTestPlugin implements BasePlugin { static DEFAULT_CONFIG = {}; + static manifest = createTestManifest( + "callbackTest", + "Callback Test Plugin", + ); name = "callbackTest"; private values: number[] = []; diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index 12165794..e4becb67 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -31,6 +31,17 @@ export { // Plugin authoring export { Plugin, toPlugin } from "./plugin"; export { analytics, server } from "./plugins"; +// Registry types for plugin manifests +export type { + ConfigSchema, + ConfigSchemaProperty, + PluginManifest, + ResourceEntry, + ResourcePermission, + ResourceRequirement, + ValidationResult, +} from "./registry"; +export { ResourceType } from "./registry"; // Telemetry (for advanced custom telemetry) export { type Counter, diff --git a/packages/appkit/src/plugin/plugin.ts b/packages/appkit/src/plugin/plugin.ts index c5050ca9..10f06ed7 100644 --- a/packages/appkit/src/plugin/plugin.ts +++ b/packages/appkit/src/plugin/plugin.ts @@ -59,7 +59,42 @@ const EXCLUDED_FROM_PROXY = new Set([ "constructor", ]); -/** Base abstract class for creating AppKit plugins */ +/** + * Base abstract class for creating AppKit plugins. + * + * Plugins can optionally declare their resource requirements through: + * 1. A static `manifest` property - recommended for all plugins + * 2. A static `getResourceRequirements()` method - for dynamic requirements + * + * @example + * ```typescript + * import { Plugin, toPlugin, PluginManifest, ResourceType } from '@databricks/appkit'; + * + * // Define manifest + * const myManifest: PluginManifest = { + * name: 'myPlugin', + * displayName: 'My Plugin', + * description: 'Does something awesome', + * resources: { + * required: [ + * { + * type: ResourceType.SQL_WAREHOUSE, + * alias: 'warehouse', + * description: 'SQL Warehouse for queries', + * permission: 'CAN_USE', + * env: 'DATABRICKS_WAREHOUSE_ID' + * } + * ], + * optional: [] + * } + * }; + * + * class MyPlugin extends Plugin { + * static manifest = myManifest; + * // ... implementation + * } + * ``` + */ export abstract class Plugin< TConfig extends BasePluginConfig = BasePluginConfig, > implements BasePlugin @@ -75,7 +110,17 @@ export abstract class Plugin< /** Registered endpoints for this plugin */ private registeredEndpoints: PluginEndpointMap = {}; + /** + * Plugin initialization phase. + * - 'core': Initialized first (e.g., config plugins) + * - 'normal': Initialized second (most plugins) + * - 'deferred': Initialized last (e.g., server plugin) + */ static phase: PluginPhase = "normal"; + + /** + * Plugin name identifier. + */ name: string; constructor(protected config: TConfig) { diff --git a/packages/appkit/src/plugins/analytics/analytics.ts b/packages/appkit/src/plugins/analytics/analytics.ts index a631a776..cc590436 100644 --- a/packages/appkit/src/plugins/analytics/analytics.ts +++ b/packages/appkit/src/plugins/analytics/analytics.ts @@ -15,6 +15,7 @@ import { import { createLogger } from "../../logging/logger"; import { Plugin, toPlugin } from "../../plugin"; import { queryDefaults } from "./defaults"; +import { analyticsManifest } from "./manifest"; import { QueryProcessor } from "./query"; import type { AnalyticsQueryResponse, @@ -28,6 +29,9 @@ export class AnalyticsPlugin extends Plugin { name = "analytics"; protected envVars: string[] = []; + /** Plugin manifest declaring metadata and resource requirements */ + static manifest = analyticsManifest; + protected static description = "Analytics plugin for data analysis"; protected declare config: IAnalyticsConfig; diff --git a/packages/appkit/src/plugins/analytics/index.ts b/packages/appkit/src/plugins/analytics/index.ts index 9ad02125..56774782 100644 --- a/packages/appkit/src/plugins/analytics/index.ts +++ b/packages/appkit/src/plugins/analytics/index.ts @@ -1,2 +1,3 @@ export * from "./analytics"; +export * from "./manifest"; export * from "./types"; diff --git a/packages/appkit/src/plugins/analytics/manifest.ts b/packages/appkit/src/plugins/analytics/manifest.ts new file mode 100644 index 00000000..bc431b93 --- /dev/null +++ b/packages/appkit/src/plugins/analytics/manifest.ts @@ -0,0 +1,49 @@ +import type { PluginManifest } from "../../registry"; +import { ResourceType } from "../../registry"; + +/** + * Analytics plugin manifest. + * + * The analytics plugin requires a SQL Warehouse for executing queries + * against Databricks data sources. + */ +export const analyticsManifest: PluginManifest = { + name: "analytics", + displayName: "Analytics Plugin", + description: "SQL query execution against Databricks SQL Warehouses", + + resources: { + required: [ + { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + description: "SQL Warehouse for executing analytics queries", + permission: "CAN_USE", + env: "DATABRICKS_WAREHOUSE_ID", + }, + ], + optional: [], + }, + + config: { + schema: { + type: "object", + properties: { + timeout: { + type: "number", + default: 30000, + description: "Query execution timeout in milliseconds", + }, + queriesDir: { + type: "string", + description: "Directory containing SQL query files", + }, + cacheEnabled: { + type: "boolean", + default: true, + description: "Enable query result caching", + }, + }, + }, + }, +}; diff --git a/packages/appkit/src/plugins/server/index.ts b/packages/appkit/src/plugins/server/index.ts index 62b3e7bd..61228f35 100644 --- a/packages/appkit/src/plugins/server/index.ts +++ b/packages/appkit/src/plugins/server/index.ts @@ -8,6 +8,7 @@ import { ServerError } from "../../errors"; import { createLogger } from "../../logging/logger"; import { Plugin, toPlugin } from "../../plugin"; import { instrumentations } from "../../telemetry"; +import { serverManifest } from "./manifest"; import { RemoteTunnelController } from "./remote-tunnel/remote-tunnel-controller"; import { StaticServer } from "./static-server"; import type { ServerConfig } from "./types"; @@ -39,6 +40,9 @@ export class ServerPlugin extends Plugin { port: Number(process.env.DATABRICKS_APP_PORT) || 8000, }; + /** Plugin manifest declaring metadata and resource requirements */ + static manifest = serverManifest; + public name = "server" as const; protected envVars: string[] = []; private serverApplication: express.Application; @@ -355,3 +359,7 @@ export const server = toPlugin( ServerPlugin, "server", ); + +// Export manifest and types +export { serverManifest } from "./manifest"; +export type { ServerConfig } from "./types"; diff --git a/packages/appkit/src/plugins/server/manifest.ts b/packages/appkit/src/plugins/server/manifest.ts new file mode 100644 index 00000000..0973230e --- /dev/null +++ b/packages/appkit/src/plugins/server/manifest.ts @@ -0,0 +1,47 @@ +import type { PluginManifest } from "../../registry"; + +/** + * Server plugin manifest. + * + * The server plugin doesn't require any Databricks resources - it only + * provides HTTP server functionality and static file serving. + */ +export const serverManifest: PluginManifest = { + name: "server", + displayName: "Server Plugin", + description: + "HTTP server with Express, static file serving, and Vite dev mode support", + + resources: { + required: [], + optional: [], + }, + + config: { + schema: { + type: "object", + properties: { + autoStart: { + type: "boolean", + default: true, + description: "Automatically start the server on plugin setup", + }, + host: { + type: "string", + default: "0.0.0.0", + description: "Host address to bind the server to", + }, + port: { + type: "number", + default: 8000, + description: "Port number for the server", + }, + staticPath: { + type: "string", + description: + "Path to static files directory (auto-detected if not provided)", + }, + }, + }, + }, +}; diff --git a/packages/appkit/src/plugins/server/tests/server.integration.test.ts b/packages/appkit/src/plugins/server/tests/server.integration.test.ts index c752f797..580b9969 100644 --- a/packages/appkit/src/plugins/server/tests/server.integration.test.ts +++ b/packages/appkit/src/plugins/server/tests/server.integration.test.ts @@ -101,6 +101,16 @@ describe("ServerPlugin with custom plugin", () => { name = "test-plugin" as const; envVars: string[] = []; + static manifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "Test plugin for integration tests", + resources: { + required: [], + optional: [], + }, + }; + injectRoutes(router: any) { router.get("/echo", (_req: any, res: any) => { res.json({ message: "hello from test plugin" }); diff --git a/packages/appkit/src/registry/index.ts b/packages/appkit/src/registry/index.ts new file mode 100644 index 00000000..a663e44a --- /dev/null +++ b/packages/appkit/src/registry/index.ts @@ -0,0 +1,14 @@ +/** + * Resource Registry System + * + * The registry system enables plugins to declare their Databricks resource + * requirements (SQL Warehouses, Lakebase instances, etc.) in a standardized way. + * + * Components: + * - Type definitions for resources, manifests, and validation + * - (Future) ResourceRegistry singleton for tracking requirements + * - (Future) Manifest loader for reading plugin declarations + * - (Future) Config generators for app.yaml, databricks.yml, .env.example + */ + +export * from "./types"; diff --git a/packages/appkit/src/registry/types.ts b/packages/appkit/src/registry/types.ts new file mode 100644 index 00000000..18216521 --- /dev/null +++ b/packages/appkit/src/registry/types.ts @@ -0,0 +1,174 @@ +/** + * Resource Registry Type System + * + * This module defines the type system for the AppKit Resource Registry, + * which enables plugins to declare their Databricks resource requirements + * in a machine-readable format. + */ + +/** + * Supported Databricks resource types that plugins can depend on. + */ +export enum ResourceType { + /** Databricks SQL Warehouse for query execution */ + SQL_WAREHOUSE = "sql-warehouse", + + /** Lakebase instance for persistent caching or data storage */ + LAKEBASE = "lakebase", + + /** Databricks Job for scheduled or triggered workflows */ + JOB = "job", + + /** Secret scope for secure credential storage */ + SECRET_SCOPE = "secret-scope", + + /** Model serving endpoint for ML inference */ + SERVING_ENDPOINT = "serving-endpoint", + + /** Vector search index for similarity search */ + VECTOR_SEARCH_INDEX = "vector-search-index", + + /** Unity Catalog for data governance and metadata */ + UNITY_CATALOG = "unity-catalog", +} + +/** + * Permission levels that can be required for a resource. + * Based on Databricks permission model. + */ +export type ResourcePermission = + | "CAN_USE" + | "CAN_MANAGE" + | "CAN_VIEW" + | "READ" + | "WRITE" + | "EXECUTE"; + +/** + * Declares a resource requirement for a plugin. + * Can be defined statically in a manifest or dynamically via getResourceRequirements(). + */ +export interface ResourceRequirement { + /** Type of Databricks resource required */ + type: ResourceType; + + /** Unique alias for this resource within the plugin (e.g., 'warehouse', 'secrets') */ + alias: string; + + /** Human-readable description of why this resource is needed */ + description: string; + + /** Required permission level for the resource */ + permission: ResourcePermission; + + /** + * Environment variable name where the resource ID/value should be provided + * Example: 'DATABRICKS_WAREHOUSE_ID', 'DATABRICKS_SECRET_SCOPE' + */ + env?: string; + + /** Whether this resource is required (true) or optional (false) */ + required: boolean; +} + +/** + * Internal representation of a resource in the registry. + * Extends ResourceRequirement with resolution state and plugin ownership. + */ +export interface ResourceEntry extends ResourceRequirement { + /** Plugin(s) that require this resource (comma-separated if multiple) */ + plugin: string; + + /** Whether the resource has been resolved (environment variable found) */ + resolved: boolean; + + /** The actual value of the resource (if resolved) */ + value?: string; +} + +/** + * Result of validating all registered resources against the environment. + */ +export interface ValidationResult { + /** Whether all required resources are available */ + valid: boolean; + + /** List of missing required resources */ + missing: ResourceEntry[]; + + /** Complete list of all registered resources (required and optional) */ + all: ResourceEntry[]; +} + +/** + * Configuration schema definition for plugin config. + * Uses JSON Schema format for validation and documentation. + */ +export interface ConfigSchema { + type: "object" | "array" | "string" | "number" | "boolean"; + properties?: Record; + items?: ConfigSchema; + required?: string[]; + additionalProperties?: boolean; + /** Allow additional JSON Schema properties */ + [key: string]: unknown; +} + +/** + * Individual property definition in a config schema. + */ +export interface ConfigSchemaProperty { + type: "object" | "array" | "string" | "number" | "boolean"; + description?: string; + default?: unknown; + enum?: unknown[]; + properties?: Record; + items?: ConfigSchemaProperty; + minimum?: number; + maximum?: number; + minLength?: number; + maxLength?: number; +} + +/** + * Plugin manifest that declares metadata and resource requirements. + * Attached to plugin classes as a static property. + */ +export interface PluginManifest { + /** Plugin identifier (matches plugin.name) */ + name: string; + + /** Human-readable display name for UI/CLI */ + displayName: string; + + /** Brief description of what the plugin does */ + description: string; + + /** + * Resource requirements declaration + */ + resources: { + /** Resources that must be available for the plugin to function */ + required: Omit[]; + + /** Resources that enhance functionality but are not mandatory */ + optional: Omit[]; + }; + + /** + * Configuration schema for the plugin. + * Defines the shape and validation rules for plugin config. + */ + config?: { + schema: ConfigSchema; + }; + + /** + * Optional metadata for community plugins + */ + author?: string; + version?: string; + repository?: string; + keywords?: string[]; + license?: string; +} diff --git a/packages/shared/src/plugin.ts b/packages/shared/src/plugin.ts index a30260aa..e390f835 100644 --- a/packages/shared/src/plugin.ts +++ b/packages/shared/src/plugin.ts @@ -46,6 +46,10 @@ export interface PluginConfig { export type PluginPhase = "core" | "normal" | "deferred"; +/** + * Plugin constructor with required manifest declaration. + * All plugins must declare a manifest with their metadata and resource requirements. + */ export type PluginConstructor< C = BasePluginConfig, I extends BasePlugin = BasePlugin, @@ -54,8 +58,57 @@ export type PluginConstructor< ) => I) & { DEFAULT_CONFIG?: Record; phase?: PluginPhase; + /** + * Static manifest declaring plugin metadata and resource requirements. + * Required for all plugins. + */ + manifest: PluginManifest; + /** + * Optional runtime resource requirements based on config. + * Use this when resource requirements depend on plugin configuration. + */ + getResourceRequirements?(config: C): ResourceRequirement[]; }; +/** + * Manifest declaration for plugins (imported from registry types). + * Re-exported here to avoid circular dependencies. + */ +export interface PluginManifest { + name: string; + displayName: string; + description: string; + resources: { + required: Omit[]; + optional: Omit[]; + }; + config?: { + schema: { + type: string; + properties?: Record; + [key: string]: unknown; + }; + }; + author?: string; + version?: string; + repository?: string; + keywords?: string[]; + license?: string; +} + +/** + * Resource requirement declaration (imported from registry types). + * Re-exported here to avoid circular dependencies. + */ +export interface ResourceRequirement { + type: string; + alias: string; + description: string; + permission: string; + env?: string; + required: boolean; +} + export type ConfigFor = T extends { DEFAULT_CONFIG: infer D } ? D : T extends new ( From 14b339ea405b606f90b335b605ce8e73d79f2e1b Mon Sep 17 00:00:00 2001 From: Mario Cadenas <17888484+MarioCadenas@users.noreply.github.com> Date: Wed, 4 Feb 2026 14:24:49 +0100 Subject: [PATCH 2/3] chore: implement manifest loader (#83) Implement manifest loader with validation and error handling. All plugins must have a static manifest property. Features: - getPluginManifest() - loads and validates plugin manifests - getResourceRequirements() - extracts resource requirements - Comprehensive validation with clear error messages - Unit tests and integration tests with core plugins --- .../appkit/src/core/tests/databricks.test.ts | 43 +- packages/appkit/src/index.ts | 8 +- packages/appkit/src/plugin/plugin.ts | 24 +- .../server/tests/server.integration.test.ts | 10 +- packages/appkit/src/registry/index.ts | 3 +- .../appkit/src/registry/manifest-loader.ts | 164 ++++++ .../src/registry/tests/integration.test.ts | 49 ++ .../registry/tests/manifest-loader.test.ts | 486 ++++++++++++++++++ 8 files changed, 746 insertions(+), 41 deletions(-) create mode 100644 packages/appkit/src/registry/manifest-loader.ts create mode 100644 packages/appkit/src/registry/tests/integration.test.ts create mode 100644 packages/appkit/src/registry/tests/manifest-loader.test.ts diff --git a/packages/appkit/src/core/tests/databricks.test.ts b/packages/appkit/src/core/tests/databricks.test.ts index 381b5136..6b4abe0d 100644 --- a/packages/appkit/src/core/tests/databricks.test.ts +++ b/packages/appkit/src/core/tests/databricks.test.ts @@ -2,20 +2,19 @@ import { mockServiceContext, setupDatabricksEnv } from "@tools/test-helpers"; import type { BasePlugin } from "shared"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { ServiceContext } from "../../context/service-context"; +import type { PluginManifest } from "../../registry/types"; import { AppKit, createApp } from "../appkit"; -// Helper function to create test manifests -function createTestManifest(name: string, displayName: string) { - return { - name, - displayName, - description: `Test plugin for ${name}`, - resources: { - required: [], - optional: [], - }, - }; -} +// Generic test manifest for test plugins +const createTestManifest = (name: string): PluginManifest => ({ + name, + displayName: `${name} Test Plugin`, + description: `Test plugin for ${name}`, + resources: { + required: [], + optional: [], + }, +}); // Mock environment validation vi.mock("../utils", () => ({ @@ -45,7 +44,7 @@ vi.mock("@databricks-apps/cache", () => ({ class CoreTestPlugin implements BasePlugin { static DEFAULT_CONFIG = { coreDefault: "core-value" }; static phase = "core" as const; - static manifest = createTestManifest("coreTest", "Core Test Plugin"); + static manifest = createTestManifest("coreTest"); name = "coreTest"; setupCalled = false; validateEnvCalled = false; @@ -82,7 +81,7 @@ class CoreTestPlugin implements BasePlugin { class NormalTestPlugin implements BasePlugin { static DEFAULT_CONFIG = { normalDefault: "normal-value" }; static phase = "normal" as const; - static manifest = createTestManifest("normalTest", "Normal Test Plugin"); + static manifest = createTestManifest("normalTest"); name = "normalTest"; setupCalled = false; validateEnvCalled = false; @@ -118,7 +117,7 @@ class NormalTestPlugin implements BasePlugin { class DeferredTestPlugin implements BasePlugin { static DEFAULT_CONFIG = { deferredDefault: "deferred-value" }; static phase = "deferred" as const; - static manifest = createTestManifest("deferredTest", "Deferred Test Plugin"); + static manifest = createTestManifest("deferredTest"); name = "deferredTest"; setupCalled = false; validateEnvCalled = false; @@ -156,7 +155,7 @@ class DeferredTestPlugin implements BasePlugin { class SlowSetupPlugin implements BasePlugin { static DEFAULT_CONFIG = {}; - static manifest = createTestManifest("slowSetup", "Slow Setup Plugin"); + static manifest = createTestManifest("slowSetup"); name = "slowSetup"; setupDelay: number; setupCalled = false; @@ -187,7 +186,7 @@ class SlowSetupPlugin implements BasePlugin { class FailingPlugin implements BasePlugin { static DEFAULT_CONFIG = {}; - static manifest = createTestManifest("failing", "Failing Plugin"); + static manifest = createTestManifest("failing"); name = "failing"; validateEnv() { @@ -545,10 +544,7 @@ describe("AppKit", () => { test("should bind SDK methods to plugin instance", async () => { class ContextTestPlugin implements BasePlugin { static DEFAULT_CONFIG = {}; - static manifest = createTestManifest( - "contextTest", - "Context Test Plugin", - ); + static manifest = createTestManifest("contextTest"); name = "contextTest"; private counter = 0; @@ -589,10 +585,7 @@ describe("AppKit", () => { test("should maintain context when SDK method is passed as callback", async () => { class CallbackTestPlugin implements BasePlugin { static DEFAULT_CONFIG = {}; - static manifest = createTestManifest( - "callbackTest", - "Callback Test Plugin", - ); + static manifest = createTestManifest("callbackTest"); name = "callbackTest"; private values: number[] = []; diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index e4becb67..5fe94593 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -31,7 +31,7 @@ export { // Plugin authoring export { Plugin, toPlugin } from "./plugin"; export { analytics, server } from "./plugins"; -// Registry types for plugin manifests +// Registry types and utilities for plugin manifests export type { ConfigSchema, ConfigSchemaProperty, @@ -41,7 +41,11 @@ export type { ResourceRequirement, ValidationResult, } from "./registry"; -export { ResourceType } from "./registry"; +export { + getPluginManifest, + getResourceRequirements, + ResourceType, +} from "./registry"; // Telemetry (for advanced custom telemetry) export { type Counter, diff --git a/packages/appkit/src/plugin/plugin.ts b/packages/appkit/src/plugin/plugin.ts index 10f06ed7..4d9c168a 100644 --- a/packages/appkit/src/plugin/plugin.ts +++ b/packages/appkit/src/plugin/plugin.ts @@ -62,15 +62,15 @@ const EXCLUDED_FROM_PROXY = new Set([ /** * Base abstract class for creating AppKit plugins. * - * Plugins can optionally declare their resource requirements through: - * 1. A static `manifest` property - recommended for all plugins - * 2. A static `getResourceRequirements()` method - for dynamic requirements + * All plugins must declare a static `manifest` property with their metadata + * and resource requirements. Plugins can also implement a static + * `getResourceRequirements()` method for dynamic requirements based on config. * * @example * ```typescript * import { Plugin, toPlugin, PluginManifest, ResourceType } from '@databricks/appkit'; * - * // Define manifest + * // Define manifest (required) * const myManifest: PluginManifest = { * name: 'myPlugin', * displayName: 'My Plugin', @@ -90,9 +90,21 @@ const EXCLUDED_FROM_PROXY = new Set([ * }; * * class MyPlugin extends Plugin { - * static manifest = myManifest; - * // ... implementation + * static manifest = myManifest; // Required! + * + * name = 'myPlugin'; + * protected envVars: string[] = []; + * + * async setup() { + * // Initialize your plugin + * } + * + * injectRoutes(router: Router) { + * // Register HTTP endpoints + * } * } + * + * export const myPlugin = toPlugin(MyPlugin, 'myPlugin'); * ``` */ export abstract class Plugin< diff --git a/packages/appkit/src/plugins/server/tests/server.integration.test.ts b/packages/appkit/src/plugins/server/tests/server.integration.test.ts index 580b9969..ded42c84 100644 --- a/packages/appkit/src/plugins/server/tests/server.integration.test.ts +++ b/packages/appkit/src/plugins/server/tests/server.integration.test.ts @@ -98,18 +98,14 @@ describe("ServerPlugin with custom plugin", () => { // Create a simple test plugin class TestPlugin extends Plugin { - name = "test-plugin" as const; - envVars: string[] = []; - static manifest = { name: "test-plugin", displayName: "Test Plugin", description: "Test plugin for integration tests", - resources: { - required: [], - optional: [], - }, + resources: { required: [], optional: [] }, }; + name = "test-plugin" as const; + envVars: string[] = []; injectRoutes(router: any) { router.get("/echo", (_req: any, res: any) => { diff --git a/packages/appkit/src/registry/index.ts b/packages/appkit/src/registry/index.ts index a663e44a..d5c0c07b 100644 --- a/packages/appkit/src/registry/index.ts +++ b/packages/appkit/src/registry/index.ts @@ -6,9 +6,10 @@ * * Components: * - Type definitions for resources, manifests, and validation + * - Manifest loader for reading plugin declarations * - (Future) ResourceRegistry singleton for tracking requirements - * - (Future) Manifest loader for reading plugin declarations * - (Future) Config generators for app.yaml, databricks.yml, .env.example */ +export { getPluginManifest, getResourceRequirements } from "./manifest-loader"; export * from "./types"; diff --git a/packages/appkit/src/registry/manifest-loader.ts b/packages/appkit/src/registry/manifest-loader.ts new file mode 100644 index 00000000..5e0dcab6 --- /dev/null +++ b/packages/appkit/src/registry/manifest-loader.ts @@ -0,0 +1,164 @@ +import type { PluginConstructor } from "shared"; +import { ConfigurationError } from "../errors"; +import { createLogger } from "../logging/logger"; +import type { PluginManifest } from "./types"; + +const logger = createLogger("manifest-loader"); + +/** + * Loads and validates the manifest from a plugin constructor. + * + * All plugins must have a static `manifest` property that declares their + * metadata and resource requirements. + * + * @param plugin - The plugin constructor class + * @returns The validated plugin manifest + * @throws {ConfigurationError} If the manifest is missing or invalid + * + * @example + * ```typescript + * import { AnalyticsPlugin } from '@databricks/appkit'; + * import { getPluginManifest } from './manifest-loader'; + * + * const manifest = getPluginManifest(AnalyticsPlugin); + * console.log('Required resources:', manifest.resources.required); + * ``` + */ +export function getPluginManifest(plugin: PluginConstructor): PluginManifest { + const pluginName = plugin.name || "unknown"; + + try { + // Check for static manifest property + if (!plugin.manifest) { + throw new ConfigurationError( + `Plugin ${pluginName} is missing a manifest. All plugins must declare a static manifest property.`, + ); + } + + // Validate manifest structure + const manifest = plugin.manifest; + + if (!manifest.name || typeof manifest.name !== "string") { + throw new ConfigurationError( + `Plugin ${pluginName} manifest has missing or invalid 'name' field`, + ); + } + + if (!manifest.displayName || typeof manifest.displayName !== "string") { + throw new ConfigurationError( + `Plugin ${manifest.name} manifest has missing or invalid 'displayName' field`, + ); + } + + if (!manifest.description || typeof manifest.description !== "string") { + throw new ConfigurationError( + `Plugin ${manifest.name} manifest has missing or invalid 'description' field`, + ); + } + + if (!manifest.resources) { + throw new ConfigurationError( + `Plugin ${manifest.name} manifest is missing 'resources' field`, + ); + } + + if (!Array.isArray(manifest.resources.required)) { + throw new ConfigurationError( + `Plugin ${manifest.name} manifest has invalid 'resources.required' field (expected array)`, + ); + } + + if ( + manifest.resources.optional && + !Array.isArray(manifest.resources.optional) + ) { + throw new ConfigurationError( + `Plugin ${manifest.name} manifest has invalid 'resources.optional' field (expected array)`, + ); + } + + logger.debug( + "Loaded manifest for plugin %s: %d required resources, %d optional resources", + manifest.name, + manifest.resources.required.length, + manifest.resources.optional?.length || 0, + ); + + // Cast to appkit PluginManifest type (structurally compatible, just more specific types) + return manifest as unknown as PluginManifest; + } catch (error) { + if (error instanceof ConfigurationError) { + throw error; + } + throw new ConfigurationError( + `Error loading manifest from plugin ${pluginName}: ${error}`, + ); + } +} + +/** + * Gets the resource requirements from a plugin's manifest. + * + * Combines required and optional resources into a single array with the + * `required` flag set appropriately. + * + * @param plugin - The plugin constructor class + * @returns Combined array of required and optional resources + * @throws {ConfigurationError} If the plugin manifest is missing or invalid + * + * @example + * ```typescript + * const resources = getResourceRequirements(AnalyticsPlugin); + * for (const resource of resources) { + * console.log(`${resource.type}: ${resource.description} (required: ${resource.required})`); + * } + * ``` + */ +export function getResourceRequirements(plugin: PluginConstructor) { + const manifest = getPluginManifest(plugin); + + const required = manifest.resources.required.map((r) => ({ + ...r, + required: true, + })); + const optional = (manifest.resources.optional || []).map((r) => ({ + ...r, + required: false, + })); + + return [...required, ...optional]; +} + +/** + * Validates a manifest object structure. + * + * @param manifest - The manifest object to validate + * @returns true if the manifest is valid, false otherwise + * + * @internal + */ +export function isValidManifest(manifest: unknown): manifest is PluginManifest { + if (!manifest || typeof manifest !== "object") { + return false; + } + + const m = manifest as Record; + + // Check required fields + if (typeof m.name !== "string") return false; + if (typeof m.displayName !== "string") return false; + if (typeof m.description !== "string") return false; + + // Check resources structure + if (!m.resources || typeof m.resources !== "object") return false; + + const resources = m.resources as Record; + if (!Array.isArray(resources.required)) return false; + + // Optional field can be missing or must be an array + if (resources.optional !== undefined && !Array.isArray(resources.optional)) { + return false; + } + + return true; +} diff --git a/packages/appkit/src/registry/tests/integration.test.ts b/packages/appkit/src/registry/tests/integration.test.ts new file mode 100644 index 00000000..ce587758 --- /dev/null +++ b/packages/appkit/src/registry/tests/integration.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { AnalyticsPlugin } from "../../plugins/analytics/analytics"; +import { ServerPlugin } from "../../plugins/server"; +import { getPluginManifest, getResourceRequirements } from "../manifest-loader"; +import { ResourceType } from "../types"; + +describe("Manifest Loader Integration", () => { + describe("ServerPlugin", () => { + it("should load manifest successfully", () => { + const manifest = getPluginManifest(ServerPlugin); + expect(manifest).not.toBeNull(); + expect(manifest?.name).toBe("server"); + expect(manifest?.displayName).toBe("Server Plugin"); + }); + + it("should have no required resources", () => { + const resources = getResourceRequirements(ServerPlugin); + expect(resources).toHaveLength(0); + }); + }); + + describe("AnalyticsPlugin", () => { + it("should load manifest successfully", () => { + const manifest = getPluginManifest(AnalyticsPlugin); + expect(manifest).not.toBeNull(); + expect(manifest?.name).toBe("analytics"); + expect(manifest?.displayName).toBe("Analytics Plugin"); + }); + + it("should require SQL Warehouse", () => { + const resources = getResourceRequirements(AnalyticsPlugin); + expect(resources).toHaveLength(1); + expect(resources[0]).toMatchObject({ + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + required: true, + permission: "CAN_USE", + env: "DATABRICKS_WAREHOUSE_ID", + }); + }); + + it("should have correct resource description", () => { + const manifest = getPluginManifest(AnalyticsPlugin); + expect(manifest?.resources.required[0].description).toBe( + "SQL Warehouse for executing analytics queries", + ); + }); + }); +}); diff --git a/packages/appkit/src/registry/tests/manifest-loader.test.ts b/packages/appkit/src/registry/tests/manifest-loader.test.ts new file mode 100644 index 00000000..0578b9dc --- /dev/null +++ b/packages/appkit/src/registry/tests/manifest-loader.test.ts @@ -0,0 +1,486 @@ +import type { PluginConstructor } from "shared"; +import { describe, expect, it } from "vitest"; +import { ConfigurationError } from "../../errors"; +import { + getPluginManifest, + getResourceRequirements, + isValidManifest, +} from "../manifest-loader"; +import type { PluginManifest } from "../types"; +import { ResourceType } from "../types"; + +describe("Manifest Loader", () => { + describe("getPluginManifest", () => { + it("should return manifest for plugin with valid manifest", () => { + const mockManifest: PluginManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [ + { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + description: "Test warehouse", + permission: "CAN_USE", + env: "TEST_WAREHOUSE_ID", + }, + ], + optional: [], + }, + }; + + class TestPlugin { + static manifest = mockManifest; + } + + const result = getPluginManifest( + TestPlugin as unknown as PluginConstructor, + ); + expect(result).toEqual(mockManifest); + }); + + it("should throw error for plugin without manifest", () => { + class PluginWithoutManifest {} + + expect(() => + getPluginManifest( + PluginWithoutManifest as unknown as PluginConstructor, + ), + ).toThrow(ConfigurationError); + expect(() => + getPluginManifest( + PluginWithoutManifest as unknown as PluginConstructor, + ), + ).toThrow(/missing a manifest/i); + }); + + it("should throw error for plugin with invalid manifest (missing name)", () => { + const invalidManifest = { + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [], + optional: [], + }, + }; + + class InvalidPlugin { + static manifest = invalidManifest; + } + + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(ConfigurationError); + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(/invalid 'name' field/i); + }); + + it("should throw error for plugin with invalid manifest (missing displayName)", () => { + const invalidManifest = { + name: "test-plugin", + description: "A test plugin", + resources: { + required: [], + optional: [], + }, + }; + + class InvalidPlugin { + static manifest = invalidManifest; + } + + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(ConfigurationError); + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(/invalid 'displayName' field/i); + }); + + it("should throw error for plugin with invalid manifest (missing description)", () => { + const invalidManifest = { + name: "test-plugin", + displayName: "Test Plugin", + resources: { + required: [], + optional: [], + }, + }; + + class InvalidPlugin { + static manifest = invalidManifest; + } + + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(ConfigurationError); + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(/invalid 'description' field/i); + }); + + it("should throw error for plugin with invalid manifest (missing resources)", () => { + const invalidManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + }; + + class InvalidPlugin { + static manifest = invalidManifest; + } + + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(ConfigurationError); + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(/missing 'resources' field/i); + }); + + it("should throw error for plugin with invalid manifest (resources.required not array)", () => { + const invalidManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: "not-an-array", + optional: [], + }, + }; + + class InvalidPlugin { + static manifest = invalidManifest; + } + + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(ConfigurationError); + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(/invalid 'resources.required' field/i); + }); + + it("should throw error for plugin with invalid manifest (resources.optional not array)", () => { + const invalidManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [], + optional: "not-an-array", + }, + }; + + class InvalidPlugin { + static manifest = invalidManifest; + } + + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(ConfigurationError); + expect(() => + getPluginManifest(InvalidPlugin as unknown as PluginConstructor), + ).toThrow(/invalid 'resources.optional' field/i); + }); + + it("should handle plugin with optional resources", () => { + const mockManifest: PluginManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [], + optional: [ + { + type: ResourceType.SECRET_SCOPE, + alias: "secrets", + description: "Optional secrets", + permission: "READ", + env: "TEST_SECRET_SCOPE", + }, + ], + }, + }; + + class TestPlugin { + static manifest = mockManifest; + } + + const result = getPluginManifest( + TestPlugin as unknown as PluginConstructor, + ); + expect(result).toEqual(mockManifest); + }); + }); + + describe("getResourceRequirements", () => { + it("should throw error for plugin without manifest", () => { + class PluginWithoutManifest {} + + expect(() => + getResourceRequirements( + PluginWithoutManifest as unknown as PluginConstructor, + ), + ).toThrow(ConfigurationError); + }); + + it("should return required resources with required=true", () => { + const mockManifest: PluginManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [ + { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + description: "Test warehouse", + permission: "CAN_USE", + env: "TEST_WAREHOUSE_ID", + }, + ], + optional: [], + }, + }; + + class TestPlugin { + static manifest = mockManifest; + } + + const resources = getResourceRequirements( + TestPlugin as unknown as PluginConstructor, + ); + expect(resources).toHaveLength(1); + expect(resources[0]).toMatchObject({ + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + required: true, + }); + }); + + it("should return optional resources with required=false", () => { + const mockManifest: PluginManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [], + optional: [ + { + type: ResourceType.SECRET_SCOPE, + alias: "secrets", + description: "Optional secrets", + permission: "READ", + env: "TEST_SECRET_SCOPE", + }, + ], + }, + }; + + class TestPlugin { + static manifest = mockManifest; + } + + const resources = getResourceRequirements( + TestPlugin as unknown as PluginConstructor, + ); + expect(resources).toHaveLength(1); + expect(resources[0]).toMatchObject({ + type: ResourceType.SECRET_SCOPE, + alias: "secrets", + required: false, + }); + }); + + it("should return both required and optional resources", () => { + const mockManifest: PluginManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [ + { + type: ResourceType.SQL_WAREHOUSE, + alias: "warehouse", + description: "Test warehouse", + permission: "CAN_USE", + env: "TEST_WAREHOUSE_ID", + }, + ], + optional: [ + { + type: ResourceType.SECRET_SCOPE, + alias: "secrets", + description: "Optional secrets", + permission: "READ", + env: "TEST_SECRET_SCOPE", + }, + ], + }, + }; + + class TestPlugin { + static manifest = mockManifest; + } + + const resources = getResourceRequirements( + TestPlugin as unknown as PluginConstructor, + ); + expect(resources).toHaveLength(2); + expect(resources[0].required).toBe(true); + expect(resources[1].required).toBe(false); + }); + + it("should return empty array for plugin with no resources", () => { + const mockManifest: PluginManifest = { + name: "test-plugin", + displayName: "Test Plugin", + description: "A test plugin", + resources: { + required: [], + optional: [], + }, + }; + + class TestPlugin { + static manifest = mockManifest; + } + + const resources = getResourceRequirements( + TestPlugin as unknown as PluginConstructor, + ); + expect(resources).toHaveLength(0); + }); + }); + + describe("isValidManifest", () => { + it("should return true for valid manifest", () => { + const validManifest: PluginManifest = { + name: "test", + displayName: "Test", + description: "Test plugin", + resources: { + required: [], + optional: [], + }, + }; + + expect(isValidManifest(validManifest)).toBe(true); + }); + + it("should return false for null", () => { + expect(isValidManifest(null)).toBe(false); + }); + + it("should return false for undefined", () => { + expect(isValidManifest(undefined)).toBe(false); + }); + + it("should return false for non-object", () => { + expect(isValidManifest("string")).toBe(false); + expect(isValidManifest(123)).toBe(false); + expect(isValidManifest(true)).toBe(false); + }); + + it("should return false for manifest missing name", () => { + const invalid = { + displayName: "Test", + description: "Test", + resources: { required: [], optional: [] }, + }; + + expect(isValidManifest(invalid)).toBe(false); + }); + + it("should return false for manifest missing displayName", () => { + const invalid = { + name: "test", + description: "Test", + resources: { required: [], optional: [] }, + }; + + expect(isValidManifest(invalid)).toBe(false); + }); + + it("should return false for manifest missing description", () => { + const invalid = { + name: "test", + displayName: "Test", + resources: { required: [], optional: [] }, + }; + + expect(isValidManifest(invalid)).toBe(false); + }); + + it("should return false for manifest missing resources", () => { + const invalid = { + name: "test", + displayName: "Test", + description: "Test", + }; + + expect(isValidManifest(invalid)).toBe(false); + }); + + it("should return false for manifest with non-array required", () => { + const invalid = { + name: "test", + displayName: "Test", + description: "Test", + resources: { + required: "not-array", + optional: [], + }, + }; + + expect(isValidManifest(invalid)).toBe(false); + }); + + it("should return false for manifest with non-array optional", () => { + const invalid = { + name: "test", + displayName: "Test", + description: "Test", + resources: { + required: [], + optional: "not-array", + }, + }; + + expect(isValidManifest(invalid)).toBe(false); + }); + + it("should return true for manifest without optional field", () => { + const valid = { + name: "test", + displayName: "Test", + description: "Test", + resources: { + required: [], + }, + }; + + expect(isValidManifest(valid)).toBe(true); + }); + + it("should return true for manifest with additional fields", () => { + const valid = { + name: "test", + displayName: "Test", + description: "Test", + resources: { + required: [], + optional: [], + }, + author: "Test Author", + version: "1.0.0", + keywords: ["test"], + }; + + expect(isValidManifest(valid)).toBe(true); + }); + }); +}); From 3e2badc82854ef4fba084b608be791571f4236e4 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 4 Feb 2026 14:50:21 +0100 Subject: [PATCH 3/3] chore: fixup --- docs/docs/api/appkit/Class.Plugin.md | 24 ++++++++--- .../api/appkit/Function.getPluginManifest.md | 36 +++++++++++++++++ .../Function.getResourceRequirements.md | 40 +++++++++++++++++++ docs/docs/api/appkit/index.md | 2 + docs/docs/api/appkit/typedoc-sidebar.ts | 10 +++++ 5 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 docs/docs/api/appkit/Function.getPluginManifest.md create mode 100644 docs/docs/api/appkit/Function.getResourceRequirements.md diff --git a/docs/docs/api/appkit/Class.Plugin.md b/docs/docs/api/appkit/Class.Plugin.md index 19fd0e9e..796b31fb 100644 --- a/docs/docs/api/appkit/Class.Plugin.md +++ b/docs/docs/api/appkit/Class.Plugin.md @@ -2,16 +2,16 @@ Base abstract class for creating AppKit plugins. -Plugins can optionally declare their resource requirements through: -1. A static `manifest` property - recommended for all plugins -2. A static `getResourceRequirements()` method - for dynamic requirements +All plugins must declare a static `manifest` property with their metadata +and resource requirements. Plugins can also implement a static +`getResourceRequirements()` method for dynamic requirements based on config. ## Example ```typescript import { Plugin, toPlugin, PluginManifest, ResourceType } from '@databricks/appkit'; -// Define manifest +// Define manifest (required) const myManifest: PluginManifest = { name: 'myPlugin', displayName: 'My Plugin', @@ -31,9 +31,21 @@ const myManifest: PluginManifest = { }; class MyPlugin extends Plugin { - static manifest = myManifest; - // ... implementation + static manifest = myManifest; // Required! + + name = 'myPlugin'; + protected envVars: string[] = []; + + async setup() { + // Initialize your plugin + } + + injectRoutes(router: Router) { + // Register HTTP endpoints + } } + +export const myPlugin = toPlugin(MyPlugin, 'myPlugin'); ``` ## Type Parameters diff --git a/docs/docs/api/appkit/Function.getPluginManifest.md b/docs/docs/api/appkit/Function.getPluginManifest.md new file mode 100644 index 00000000..3afb325d --- /dev/null +++ b/docs/docs/api/appkit/Function.getPluginManifest.md @@ -0,0 +1,36 @@ +# Function: getPluginManifest() + +```ts +function getPluginManifest(plugin: PluginConstructor): PluginManifest; +``` + +Loads and validates the manifest from a plugin constructor. + +All plugins must have a static `manifest` property that declares their +metadata and resource requirements. + +## Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `plugin` | `PluginConstructor` | The plugin constructor class | + +## Returns + +[`PluginManifest`](Interface.PluginManifest.md) + +The validated plugin manifest + +## Throws + +If the manifest is missing or invalid + +## Example + +```typescript +import { AnalyticsPlugin } from '@databricks/appkit'; +import { getPluginManifest } from './manifest-loader'; + +const manifest = getPluginManifest(AnalyticsPlugin); +console.log('Required resources:', manifest.resources.required); +``` diff --git a/docs/docs/api/appkit/Function.getResourceRequirements.md b/docs/docs/api/appkit/Function.getResourceRequirements.md new file mode 100644 index 00000000..12ea5068 --- /dev/null +++ b/docs/docs/api/appkit/Function.getResourceRequirements.md @@ -0,0 +1,40 @@ +# Function: getResourceRequirements() + +```ts +function getResourceRequirements(plugin: PluginConstructor): { + alias: string; + description: string; + env?: string; + permission: ResourcePermission; + required: boolean; + type: ResourceType; +}[]; +``` + +Gets the resource requirements from a plugin's manifest. + +Combines required and optional resources into a single array with the +`required` flag set appropriately. + +## Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `plugin` | `PluginConstructor` | The plugin constructor class | + +## Returns + +Combined array of required and optional resources + +## Throws + +If the plugin manifest is missing or invalid + +## Example + +```typescript +const resources = getResourceRequirements(AnalyticsPlugin); +for (const resource of resources) { + console.log(`${resource.type}: ${resource.description} (required: ${resource.required})`); +} +``` diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index b3fd4ccd..ac912bf2 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -60,4 +60,6 @@ plugin architecture, and React integration. | [appKitTypesPlugin](Function.appKitTypesPlugin.md) | Vite plugin to generate types for AppKit queries. Calls generateFromEntryPoint under the hood. | | [createApp](Function.createApp.md) | Bootstraps AppKit with the provided configuration. | | [getExecutionContext](Function.getExecutionContext.md) | Get the current execution context. | +| [getPluginManifest](Function.getPluginManifest.md) | Loads and validates the manifest from a plugin constructor. | +| [getResourceRequirements](Function.getResourceRequirements.md) | Gets the resource requirements from a plugin's manifest. | | [isSQLTypeMarker](Function.isSQLTypeMarker.md) | Type guard to check if a value is a SQL type marker | diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index 93381150..c310eb62 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -175,6 +175,16 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Function.getExecutionContext", label: "getExecutionContext" }, + { + type: "doc", + id: "api/appkit/Function.getPluginManifest", + label: "getPluginManifest" + }, + { + type: "doc", + id: "api/appkit/Function.getResourceRequirements", + label: "getResourceRequirements" + }, { type: "doc", id: "api/appkit/Function.isSQLTypeMarker",