Skip to content

Commit 14b339e

Browse files
committed
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
1 parent eb19da1 commit 14b339e

File tree

8 files changed

+746
-41
lines changed

8 files changed

+746
-41
lines changed

packages/appkit/src/core/tests/databricks.test.ts

Lines changed: 18 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,19 @@ import { mockServiceContext, setupDatabricksEnv } from "@tools/test-helpers";
22
import type { BasePlugin } from "shared";
33
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
44
import { ServiceContext } from "../../context/service-context";
5+
import type { PluginManifest } from "../../registry/types";
56
import { AppKit, createApp } from "../appkit";
67

7-
// Helper function to create test manifests
8-
function createTestManifest(name: string, displayName: string) {
9-
return {
10-
name,
11-
displayName,
12-
description: `Test plugin for ${name}`,
13-
resources: {
14-
required: [],
15-
optional: [],
16-
},
17-
};
18-
}
8+
// Generic test manifest for test plugins
9+
const createTestManifest = (name: string): PluginManifest => ({
10+
name,
11+
displayName: `${name} Test Plugin`,
12+
description: `Test plugin for ${name}`,
13+
resources: {
14+
required: [],
15+
optional: [],
16+
},
17+
});
1918

2019
// Mock environment validation
2120
vi.mock("../utils", () => ({
@@ -45,7 +44,7 @@ vi.mock("@databricks-apps/cache", () => ({
4544
class CoreTestPlugin implements BasePlugin {
4645
static DEFAULT_CONFIG = { coreDefault: "core-value" };
4746
static phase = "core" as const;
48-
static manifest = createTestManifest("coreTest", "Core Test Plugin");
47+
static manifest = createTestManifest("coreTest");
4948
name = "coreTest";
5049
setupCalled = false;
5150
validateEnvCalled = false;
@@ -82,7 +81,7 @@ class CoreTestPlugin implements BasePlugin {
8281
class NormalTestPlugin implements BasePlugin {
8382
static DEFAULT_CONFIG = { normalDefault: "normal-value" };
8483
static phase = "normal" as const;
85-
static manifest = createTestManifest("normalTest", "Normal Test Plugin");
84+
static manifest = createTestManifest("normalTest");
8685
name = "normalTest";
8786
setupCalled = false;
8887
validateEnvCalled = false;
@@ -118,7 +117,7 @@ class NormalTestPlugin implements BasePlugin {
118117
class DeferredTestPlugin implements BasePlugin {
119118
static DEFAULT_CONFIG = { deferredDefault: "deferred-value" };
120119
static phase = "deferred" as const;
121-
static manifest = createTestManifest("deferredTest", "Deferred Test Plugin");
120+
static manifest = createTestManifest("deferredTest");
122121
name = "deferredTest";
123122
setupCalled = false;
124123
validateEnvCalled = false;
@@ -156,7 +155,7 @@ class DeferredTestPlugin implements BasePlugin {
156155

157156
class SlowSetupPlugin implements BasePlugin {
158157
static DEFAULT_CONFIG = {};
159-
static manifest = createTestManifest("slowSetup", "Slow Setup Plugin");
158+
static manifest = createTestManifest("slowSetup");
160159
name = "slowSetup";
161160
setupDelay: number;
162161
setupCalled = false;
@@ -187,7 +186,7 @@ class SlowSetupPlugin implements BasePlugin {
187186

188187
class FailingPlugin implements BasePlugin {
189188
static DEFAULT_CONFIG = {};
190-
static manifest = createTestManifest("failing", "Failing Plugin");
189+
static manifest = createTestManifest("failing");
191190
name = "failing";
192191

193192
validateEnv() {
@@ -545,10 +544,7 @@ describe("AppKit", () => {
545544
test("should bind SDK methods to plugin instance", async () => {
546545
class ContextTestPlugin implements BasePlugin {
547546
static DEFAULT_CONFIG = {};
548-
static manifest = createTestManifest(
549-
"contextTest",
550-
"Context Test Plugin",
551-
);
547+
static manifest = createTestManifest("contextTest");
552548
name = "contextTest";
553549
private counter = 0;
554550

@@ -589,10 +585,7 @@ describe("AppKit", () => {
589585
test("should maintain context when SDK method is passed as callback", async () => {
590586
class CallbackTestPlugin implements BasePlugin {
591587
static DEFAULT_CONFIG = {};
592-
static manifest = createTestManifest(
593-
"callbackTest",
594-
"Callback Test Plugin",
595-
);
588+
static manifest = createTestManifest("callbackTest");
596589
name = "callbackTest";
597590
private values: number[] = [];
598591

packages/appkit/src/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export {
3131
// Plugin authoring
3232
export { Plugin, toPlugin } from "./plugin";
3333
export { analytics, server } from "./plugins";
34-
// Registry types for plugin manifests
34+
// Registry types and utilities for plugin manifests
3535
export type {
3636
ConfigSchema,
3737
ConfigSchemaProperty,
@@ -41,7 +41,11 @@ export type {
4141
ResourceRequirement,
4242
ValidationResult,
4343
} from "./registry";
44-
export { ResourceType } from "./registry";
44+
export {
45+
getPluginManifest,
46+
getResourceRequirements,
47+
ResourceType,
48+
} from "./registry";
4549
// Telemetry (for advanced custom telemetry)
4650
export {
4751
type Counter,

packages/appkit/src/plugin/plugin.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,15 @@ const EXCLUDED_FROM_PROXY = new Set([
6262
/**
6363
* Base abstract class for creating AppKit plugins.
6464
*
65-
* Plugins can optionally declare their resource requirements through:
66-
* 1. A static `manifest` property - recommended for all plugins
67-
* 2. A static `getResourceRequirements()` method - for dynamic requirements
65+
* All plugins must declare a static `manifest` property with their metadata
66+
* and resource requirements. Plugins can also implement a static
67+
* `getResourceRequirements()` method for dynamic requirements based on config.
6868
*
6969
* @example
7070
* ```typescript
7171
* import { Plugin, toPlugin, PluginManifest, ResourceType } from '@databricks/appkit';
7272
*
73-
* // Define manifest
73+
* // Define manifest (required)
7474
* const myManifest: PluginManifest = {
7575
* name: 'myPlugin',
7676
* displayName: 'My Plugin',
@@ -90,9 +90,21 @@ const EXCLUDED_FROM_PROXY = new Set([
9090
* };
9191
*
9292
* class MyPlugin extends Plugin<MyConfig> {
93-
* static manifest = myManifest;
94-
* // ... implementation
93+
* static manifest = myManifest; // Required!
94+
*
95+
* name = 'myPlugin';
96+
* protected envVars: string[] = [];
97+
*
98+
* async setup() {
99+
* // Initialize your plugin
100+
* }
101+
*
102+
* injectRoutes(router: Router) {
103+
* // Register HTTP endpoints
104+
* }
95105
* }
106+
*
107+
* export const myPlugin = toPlugin(MyPlugin, 'myPlugin');
96108
* ```
97109
*/
98110
export abstract class Plugin<

packages/appkit/src/plugins/server/tests/server.integration.test.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,18 +98,14 @@ describe("ServerPlugin with custom plugin", () => {
9898

9999
// Create a simple test plugin
100100
class TestPlugin extends Plugin {
101-
name = "test-plugin" as const;
102-
envVars: string[] = [];
103-
104101
static manifest = {
105102
name: "test-plugin",
106103
displayName: "Test Plugin",
107104
description: "Test plugin for integration tests",
108-
resources: {
109-
required: [],
110-
optional: [],
111-
},
105+
resources: { required: [], optional: [] },
112106
};
107+
name = "test-plugin" as const;
108+
envVars: string[] = [];
113109

114110
injectRoutes(router: any) {
115111
router.get("/echo", (_req: any, res: any) => {

packages/appkit/src/registry/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66
*
77
* Components:
88
* - Type definitions for resources, manifests, and validation
9+
* - Manifest loader for reading plugin declarations
910
* - (Future) ResourceRegistry singleton for tracking requirements
10-
* - (Future) Manifest loader for reading plugin declarations
1111
* - (Future) Config generators for app.yaml, databricks.yml, .env.example
1212
*/
1313

14+
export { getPluginManifest, getResourceRequirements } from "./manifest-loader";
1415
export * from "./types";
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import type { PluginConstructor } from "shared";
2+
import { ConfigurationError } from "../errors";
3+
import { createLogger } from "../logging/logger";
4+
import type { PluginManifest } from "./types";
5+
6+
const logger = createLogger("manifest-loader");
7+
8+
/**
9+
* Loads and validates the manifest from a plugin constructor.
10+
*
11+
* All plugins must have a static `manifest` property that declares their
12+
* metadata and resource requirements.
13+
*
14+
* @param plugin - The plugin constructor class
15+
* @returns The validated plugin manifest
16+
* @throws {ConfigurationError} If the manifest is missing or invalid
17+
*
18+
* @example
19+
* ```typescript
20+
* import { AnalyticsPlugin } from '@databricks/appkit';
21+
* import { getPluginManifest } from './manifest-loader';
22+
*
23+
* const manifest = getPluginManifest(AnalyticsPlugin);
24+
* console.log('Required resources:', manifest.resources.required);
25+
* ```
26+
*/
27+
export function getPluginManifest(plugin: PluginConstructor): PluginManifest {
28+
const pluginName = plugin.name || "unknown";
29+
30+
try {
31+
// Check for static manifest property
32+
if (!plugin.manifest) {
33+
throw new ConfigurationError(
34+
`Plugin ${pluginName} is missing a manifest. All plugins must declare a static manifest property.`,
35+
);
36+
}
37+
38+
// Validate manifest structure
39+
const manifest = plugin.manifest;
40+
41+
if (!manifest.name || typeof manifest.name !== "string") {
42+
throw new ConfigurationError(
43+
`Plugin ${pluginName} manifest has missing or invalid 'name' field`,
44+
);
45+
}
46+
47+
if (!manifest.displayName || typeof manifest.displayName !== "string") {
48+
throw new ConfigurationError(
49+
`Plugin ${manifest.name} manifest has missing or invalid 'displayName' field`,
50+
);
51+
}
52+
53+
if (!manifest.description || typeof manifest.description !== "string") {
54+
throw new ConfigurationError(
55+
`Plugin ${manifest.name} manifest has missing or invalid 'description' field`,
56+
);
57+
}
58+
59+
if (!manifest.resources) {
60+
throw new ConfigurationError(
61+
`Plugin ${manifest.name} manifest is missing 'resources' field`,
62+
);
63+
}
64+
65+
if (!Array.isArray(manifest.resources.required)) {
66+
throw new ConfigurationError(
67+
`Plugin ${manifest.name} manifest has invalid 'resources.required' field (expected array)`,
68+
);
69+
}
70+
71+
if (
72+
manifest.resources.optional &&
73+
!Array.isArray(manifest.resources.optional)
74+
) {
75+
throw new ConfigurationError(
76+
`Plugin ${manifest.name} manifest has invalid 'resources.optional' field (expected array)`,
77+
);
78+
}
79+
80+
logger.debug(
81+
"Loaded manifest for plugin %s: %d required resources, %d optional resources",
82+
manifest.name,
83+
manifest.resources.required.length,
84+
manifest.resources.optional?.length || 0,
85+
);
86+
87+
// Cast to appkit PluginManifest type (structurally compatible, just more specific types)
88+
return manifest as unknown as PluginManifest;
89+
} catch (error) {
90+
if (error instanceof ConfigurationError) {
91+
throw error;
92+
}
93+
throw new ConfigurationError(
94+
`Error loading manifest from plugin ${pluginName}: ${error}`,
95+
);
96+
}
97+
}
98+
99+
/**
100+
* Gets the resource requirements from a plugin's manifest.
101+
*
102+
* Combines required and optional resources into a single array with the
103+
* `required` flag set appropriately.
104+
*
105+
* @param plugin - The plugin constructor class
106+
* @returns Combined array of required and optional resources
107+
* @throws {ConfigurationError} If the plugin manifest is missing or invalid
108+
*
109+
* @example
110+
* ```typescript
111+
* const resources = getResourceRequirements(AnalyticsPlugin);
112+
* for (const resource of resources) {
113+
* console.log(`${resource.type}: ${resource.description} (required: ${resource.required})`);
114+
* }
115+
* ```
116+
*/
117+
export function getResourceRequirements(plugin: PluginConstructor) {
118+
const manifest = getPluginManifest(plugin);
119+
120+
const required = manifest.resources.required.map((r) => ({
121+
...r,
122+
required: true,
123+
}));
124+
const optional = (manifest.resources.optional || []).map((r) => ({
125+
...r,
126+
required: false,
127+
}));
128+
129+
return [...required, ...optional];
130+
}
131+
132+
/**
133+
* Validates a manifest object structure.
134+
*
135+
* @param manifest - The manifest object to validate
136+
* @returns true if the manifest is valid, false otherwise
137+
*
138+
* @internal
139+
*/
140+
export function isValidManifest(manifest: unknown): manifest is PluginManifest {
141+
if (!manifest || typeof manifest !== "object") {
142+
return false;
143+
}
144+
145+
const m = manifest as Record<string, unknown>;
146+
147+
// Check required fields
148+
if (typeof m.name !== "string") return false;
149+
if (typeof m.displayName !== "string") return false;
150+
if (typeof m.description !== "string") return false;
151+
152+
// Check resources structure
153+
if (!m.resources || typeof m.resources !== "object") return false;
154+
155+
const resources = m.resources as Record<string, unknown>;
156+
if (!Array.isArray(resources.required)) return false;
157+
158+
// Optional field can be missing or must be an array
159+
if (resources.optional !== undefined && !Array.isArray(resources.optional)) {
160+
return false;
161+
}
162+
163+
return true;
164+
}

0 commit comments

Comments
 (0)