mirror of
https://github.com/unraid/api.git
synced 2026-02-05 07:29:21 -06:00
refactor(api): use nestjs modules as the api plugin interface (#1321)
Changes plugin interface to expect Nest modules instead of a custom plain JS object. ## Summary by CodeRabbit - **New Features** - Introduced updated health check plugins, including a new connect plugin exposing a GraphQL health query and configuration logging. - **Refactor** - Streamlined the overall plugin registration and management across API, CLI, and GraphQL modules for improved clarity and logging. - Simplified the `CliModule` and `PluginService` to reduce complexity and enhance maintainability. - **Chores** - Updated dependency configurations and build scripts while introducing new TypeScript setups to enhance maintainability and consistency. - Added new `package.json` and `tsconfig.json` files for the `unraid-api-plugin-connect` and `unraid-api-plugin-health` projects. - Modified GitHub Actions workflow to update tag format for pull requests.
This commit is contained in:
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
@@ -337,7 +337,7 @@ jobs:
|
||||
uses: ./.github/workflows/build-plugin.yml
|
||||
with:
|
||||
RELEASE_CREATED: false
|
||||
TAG: ${{ github.event.pull_request.number }}
|
||||
TAG: PR${{ github.event.pull_request.number }}
|
||||
BUCKET_PATH: ${{ github.event.pull_request.number && format('unraid-api/tag/PR{0}', github.event.pull_request.number) || 'unraid-api' }}
|
||||
BASE_URL: "https://preview.dl.unraid.net/unraid-api"
|
||||
secrets:
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
"@jsonforms/core": "^3.5.1",
|
||||
"@nestjs/apollo": "^13.0.3",
|
||||
"@nestjs/common": "^11.0.11",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.11",
|
||||
"@nestjs/graphql": "^13.0.3",
|
||||
"@nestjs/passport": "^11.0.0",
|
||||
|
||||
@@ -20,16 +20,7 @@ const getUnraidApiLocation = async () => {
|
||||
};
|
||||
|
||||
try {
|
||||
// Register plugins and create a dynamic module configuration
|
||||
const dynamicModule = await CliModule.registerWithPlugins();
|
||||
|
||||
// Create a new class that extends CliModule with the dynamic configuration
|
||||
const DynamicCliModule = class extends CliModule {
|
||||
static module = dynamicModule.module;
|
||||
static imports = dynamicModule.imports;
|
||||
static providers = dynamicModule.providers;
|
||||
};
|
||||
await CommandFactory.run(DynamicCliModule, {
|
||||
await CommandFactory.run(CliModule, {
|
||||
cliName: 'unraid-api',
|
||||
logger: LOG_LEVEL === 'TRACE' ? new LogService() : false, // - enable this to see nest initialization issues
|
||||
completion: {
|
||||
|
||||
@@ -47,7 +47,7 @@ import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/u
|
||||
},
|
||||
]),
|
||||
UnraidFileModifierModule,
|
||||
PluginModule.registerPlugins(),
|
||||
PluginModule.register(),
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { DynamicModule, Module, Provider, Type } from '@nestjs/common';
|
||||
|
||||
import { CommandRunner } from 'nest-commander';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AddApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/add-api-key.questions.js';
|
||||
@@ -26,9 +24,7 @@ import { StatusCommand } from '@app/unraid-api/cli/status.command.js';
|
||||
import { StopCommand } from '@app/unraid-api/cli/stop.command.js';
|
||||
import { SwitchEnvCommand } from '@app/unraid-api/cli/switch-env.command.js';
|
||||
import { VersionCommand } from '@app/unraid-api/cli/version.command.js';
|
||||
import { ApiPluginDefinition } from '@app/unraid-api/plugin/plugin.interface.js';
|
||||
import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js';
|
||||
import { PluginService } from '@app/unraid-api/plugin/plugin.service.js';
|
||||
import { PluginCliModule } from '@app/unraid-api/plugin/plugin.module.js';
|
||||
|
||||
const DEFAULT_COMMANDS = [
|
||||
ApiKeyCommand,
|
||||
@@ -60,43 +56,8 @@ const DEFAULT_PROVIDERS = [
|
||||
ApiKeyService,
|
||||
] as const;
|
||||
|
||||
type PluginProvider = Provider & {
|
||||
provide: string | symbol | Type<any>;
|
||||
useValue?: ApiPluginDefinition;
|
||||
};
|
||||
|
||||
@Module({
|
||||
imports: [PluginModule],
|
||||
imports: [PluginCliModule.register()],
|
||||
providers: [...DEFAULT_COMMANDS, ...DEFAULT_PROVIDERS],
|
||||
})
|
||||
export class CliModule {
|
||||
/**
|
||||
* Get all registered commands
|
||||
* @returns Array of registered command classes
|
||||
*/
|
||||
static getCommands(): Type<CommandRunner>[] {
|
||||
return [...DEFAULT_COMMANDS];
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the module with plugin support
|
||||
* @returns DynamicModule configuration including plugin commands
|
||||
*/
|
||||
static async registerWithPlugins(): Promise<DynamicModule> {
|
||||
const pluginModule = await PluginModule.registerPlugins();
|
||||
|
||||
// Get commands from plugins
|
||||
const pluginCommands: Type<CommandRunner>[] = [];
|
||||
for (const provider of (pluginModule.providers || []) as PluginProvider[]) {
|
||||
if (provider.provide !== PluginService && provider.useValue?.commands) {
|
||||
pluginCommands.push(...provider.useValue.commands);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
module: CliModule,
|
||||
imports: [pluginModule],
|
||||
providers: [...DEFAULT_COMMANDS, ...DEFAULT_PROVIDERS, ...pluginCommands],
|
||||
};
|
||||
}
|
||||
}
|
||||
export class CliModule {}
|
||||
|
||||
@@ -15,12 +15,10 @@ import {
|
||||
import { GraphQLLong } from '@app/graphql/resolvers/graphql-type-long.js';
|
||||
import { loadTypeDefs } from '@app/graphql/schema/loadTypesDefs.js';
|
||||
import { getters } from '@app/store/index.js';
|
||||
import { AuthModule } from '@app/unraid-api/auth/auth.module.js';
|
||||
import { idPrefixPlugin } from '@app/unraid-api/graph/id-prefix-plugin.js';
|
||||
import { ResolversModule } from '@app/unraid-api/graph/resolvers/resolvers.module.js';
|
||||
import { sandboxPlugin } from '@app/unraid-api/graph/sandbox-plugin.js';
|
||||
import { getAuthEnumTypeDefs } from '@app/unraid-api/graph/utils/auth-enum.utils.js';
|
||||
import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js';
|
||||
import { PluginService } from '@app/unraid-api/plugin/plugin.service.js';
|
||||
|
||||
@Module({
|
||||
@@ -28,12 +26,10 @@ import { PluginService } from '@app/unraid-api/plugin/plugin.service.js';
|
||||
ResolversModule,
|
||||
GraphQLModule.forRootAsync<ApolloDriverConfig>({
|
||||
driver: ApolloDriver,
|
||||
imports: [PluginModule, AuthModule],
|
||||
inject: [PluginService],
|
||||
useFactory: async (pluginService: PluginService) => {
|
||||
const plugins = await pluginService.getGraphQLConfiguration();
|
||||
useFactory: async () => {
|
||||
const pluginSchemas = await PluginService.getGraphQLSchemas();
|
||||
const authEnumTypeDefs = getAuthEnumTypeDefs();
|
||||
const typeDefs = print(await loadTypeDefs([plugins.typeDefs, authEnumTypeDefs]));
|
||||
const typeDefs = print(await loadTypeDefs([...pluginSchemas, authEnumTypeDefs]));
|
||||
const resolvers = {
|
||||
DateTime: DateTimeResolver,
|
||||
JSON: JSONResolver,
|
||||
@@ -41,9 +37,7 @@ import { PluginService } from '@app/unraid-api/plugin/plugin.service.js';
|
||||
Port: PortResolver,
|
||||
URL: URLResolver,
|
||||
UUID: UUIDResolver,
|
||||
...plugins.resolvers,
|
||||
};
|
||||
|
||||
return {
|
||||
introspection: getters.config()?.local?.sandbox === 'yes',
|
||||
playground: false,
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { Logger, Type } from '@nestjs/common';
|
||||
|
||||
import { CommandRunner } from 'nest-commander';
|
||||
import type { Constructor } from 'type-fest';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ApiStore } from '@app/store/index.js';
|
||||
|
||||
export interface PluginMetadata {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const asyncArray = () => z.function().returns(z.promise(z.array(z.any())));
|
||||
const asyncString = () => z.function().returns(z.promise(z.string()));
|
||||
const asyncVoid = () => z.function().returns(z.promise(z.void()));
|
||||
@@ -32,30 +23,44 @@ const resolverTypeMap = z.record(
|
||||
);
|
||||
const asyncResolver = () => z.function().returns(z.promise(resolverTypeMap));
|
||||
|
||||
/** Warning: unstable API. The config mechanism and API may soon change. */
|
||||
export const apiPluginSchema = z.object({
|
||||
_type: z.literal('UnraidApiPlugin'),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
commands: z.array(z.custom<Type<CommandRunner>>()),
|
||||
registerGraphQLResolvers: asyncResolver().optional(),
|
||||
registerGraphQLTypeDefs: asyncString().optional(),
|
||||
registerRESTControllers: asyncArray().optional(),
|
||||
registerRESTRoutes: asyncArray().optional(),
|
||||
registerServices: asyncArray().optional(),
|
||||
registerCronJobs: asyncArray().optional(),
|
||||
// These schema definitions are picked up as nest modules as well.
|
||||
onModuleInit: asyncVoid().optional(),
|
||||
onModuleDestroy: asyncVoid().optional(),
|
||||
});
|
||||
type NestModule = Constructor<unknown>;
|
||||
const isClass = (value: unknown): value is NestModule => {
|
||||
return typeof value === 'function' && value.toString().startsWith('class');
|
||||
};
|
||||
|
||||
/** Warning: unstable API. The config mechanism and API may soon change. */
|
||||
export type ApiPluginDefinition = z.infer<typeof apiPluginSchema>;
|
||||
/** format of module exports from a nestjs plugin */
|
||||
export const apiNestPluginSchema = z
|
||||
.object({
|
||||
adapter: z.literal('nestjs'),
|
||||
ApiModule: z
|
||||
.custom<NestModule>(isClass, {
|
||||
message: 'Invalid NestJS module: expected a class constructor',
|
||||
})
|
||||
.optional(),
|
||||
CliModule: z
|
||||
.custom<NestModule>(isClass, {
|
||||
message: 'Invalid NestJS module: expected a class constructor',
|
||||
})
|
||||
.optional(),
|
||||
graphqlSchemaExtension: asyncString().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
// Ensure that at least one of ApiModule or CliModule is defined.
|
||||
if (!data.ApiModule && !data.CliModule) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'At least one of ApiModule or CliModule must be defined',
|
||||
path: ['ApiModule', 'CliModule'],
|
||||
});
|
||||
}
|
||||
// If graphqlSchemaExtension is provided, ensure that ApiModule is defined.
|
||||
if (data.graphqlSchemaExtension && !data.ApiModule) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'If graphqlSchemaExtension is provided, ApiModule must be defined',
|
||||
path: ['graphqlSchemaExtension'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// todo: the blocker to publishing this type is the 'ApiStore' type.
|
||||
// It pulls in a lot of irrelevant types (e.g. graphql types) and triggers js transpilation of everything related to the store.
|
||||
// If we can isolate the type, we can publish it to npm and developers can use it as a dev dependency.
|
||||
/**
|
||||
* Represents a subclass of UnraidAPIPlugin that can be instantiated.
|
||||
*/
|
||||
export type ConstructablePlugin = (options: { store: ApiStore; logger: Logger }) => ApiPluginDefinition;
|
||||
export type ApiNestPluginDefinition = z.infer<typeof apiNestPluginSchema>;
|
||||
|
||||
@@ -7,14 +7,41 @@ export class PluginModule {
|
||||
private static readonly logger = new Logger(PluginModule.name);
|
||||
constructor(private readonly pluginService: PluginService) {}
|
||||
|
||||
static async registerPlugins(): Promise<DynamicModule> {
|
||||
static async register(): Promise<DynamicModule> {
|
||||
const plugins = await PluginService.getPlugins();
|
||||
const providers = plugins.map((result) => result.provider);
|
||||
const apiModules = plugins
|
||||
.filter((plugin) => plugin.ApiModule)
|
||||
.map((plugin) => plugin.ApiModule!);
|
||||
|
||||
const pluginList = apiModules.map((plugin) => plugin.name).join(', ');
|
||||
PluginModule.logger.log(`Found ${apiModules.length} API plugins: ${pluginList}`);
|
||||
|
||||
return {
|
||||
module: PluginModule,
|
||||
providers: [PluginService, ...providers],
|
||||
exports: [PluginService, ...providers.map((p) => p.provide)],
|
||||
imports: [...apiModules],
|
||||
providers: [PluginService],
|
||||
exports: [PluginService],
|
||||
global: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Module({})
|
||||
export class PluginCliModule {
|
||||
private static readonly logger = new Logger(PluginCliModule.name);
|
||||
|
||||
static async register(): Promise<DynamicModule> {
|
||||
const plugins = await PluginService.getPlugins();
|
||||
const cliModules = plugins
|
||||
.filter((plugin) => plugin.CliModule)
|
||||
.map((plugin) => plugin.CliModule!);
|
||||
|
||||
const cliList = cliModules.map((plugin) => plugin.name).join(', ');
|
||||
PluginCliModule.logger.log(`Found ${cliModules.length} CLI plugins: ${cliList}`);
|
||||
|
||||
return {
|
||||
module: PluginCliModule,
|
||||
imports: [...cliModules],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,225 +1,80 @@
|
||||
import { Injectable, Logger, Provider, Type } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { pascalCase } from 'change-case';
|
||||
import type { SetRequired } from 'type-fest';
|
||||
import { parse } from 'graphql';
|
||||
|
||||
import type {
|
||||
ApiPluginDefinition,
|
||||
ConstructablePlugin,
|
||||
} from '@app/unraid-api/plugin/plugin.interface.js';
|
||||
import { getPackageJsonDependencies as getPackageDependencies } from '@app/environment.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
import { apiPluginSchema } from '@app/unraid-api/plugin/plugin.interface.js';
|
||||
import type { ApiNestPluginDefinition } from '@app/unraid-api/plugin/plugin.interface.js';
|
||||
import { getPackageJson } from '@app/environment.js';
|
||||
import { apiNestPluginSchema } from '@app/unraid-api/plugin/plugin.interface.js';
|
||||
import { batchProcess } from '@app/utils.js';
|
||||
|
||||
type CustomProvider = Provider & {
|
||||
provide: string | symbol | Type<any>;
|
||||
};
|
||||
|
||||
type PluginProvider = {
|
||||
provider: CustomProvider;
|
||||
pluginInstance: ApiPluginDefinition;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PluginService {
|
||||
private pluginProviders: PluginProvider[] | undefined;
|
||||
private loadingPromise: Promise<PluginProvider[]> | undefined;
|
||||
private static readonly logger = new Logger(PluginService.name);
|
||||
constructor() {
|
||||
this.loadPlugins();
|
||||
}
|
||||
|
||||
private get plugins() {
|
||||
return this.pluginProviders?.map((plugin) => plugin.pluginInstance) ?? [];
|
||||
}
|
||||
|
||||
async loadPlugins() {
|
||||
// If plugins are already loaded, return them
|
||||
if (this.pluginProviders?.length) {
|
||||
return this.pluginProviders;
|
||||
}
|
||||
|
||||
// If getPlugins() is already loading, return its promise
|
||||
if (this.loadingPromise) {
|
||||
return this.loadingPromise;
|
||||
}
|
||||
|
||||
this.loadingPromise = PluginService.getPlugins()
|
||||
.then((plugins) => {
|
||||
if (!this.pluginProviders?.length) {
|
||||
this.pluginProviders = plugins;
|
||||
const pluginNames = this.plugins.map((plugin) => plugin.name);
|
||||
PluginService.logger.debug(
|
||||
`Registered ${pluginNames.length} plugins: ${pluginNames.join(', ')}`
|
||||
);
|
||||
} else {
|
||||
PluginService.logger.debug(
|
||||
`${plugins.length} plugins already registered. Skipping registration.`
|
||||
);
|
||||
}
|
||||
return this.pluginProviders;
|
||||
})
|
||||
.catch((error) => {
|
||||
PluginService.logger.error('Error registering plugins', error);
|
||||
return [];
|
||||
})
|
||||
.finally(() => {
|
||||
// clear loading state
|
||||
this.loadingPromise = undefined;
|
||||
});
|
||||
|
||||
return this.loadingPromise;
|
||||
}
|
||||
|
||||
private static isPluginFactory(factory: unknown): factory is ConstructablePlugin {
|
||||
return typeof factory === 'function';
|
||||
}
|
||||
|
||||
private static async getPluginFromPackage(pluginPackage: string): Promise<{
|
||||
provider: CustomProvider;
|
||||
pluginInstance: ApiPluginDefinition;
|
||||
}> {
|
||||
const moduleImport = await import(/* @vite-ignore */ pluginPackage);
|
||||
const pluginName = pascalCase(pluginPackage);
|
||||
const PluginFactory = moduleImport.default || moduleImport[pluginName];
|
||||
|
||||
if (!PluginService.isPluginFactory(PluginFactory)) {
|
||||
throw new Error(`Invalid plugin from ${pluginPackage}. Must export a factory function.`);
|
||||
}
|
||||
|
||||
const logger = new Logger(PluginFactory.name);
|
||||
const validation = apiPluginSchema.safeParse(PluginFactory({ store, logger }));
|
||||
if (!validation.success) {
|
||||
throw new Error(`Invalid plugin from ${pluginPackage}: ${validation.error}`);
|
||||
}
|
||||
const pluginInstance = validation.data;
|
||||
|
||||
return {
|
||||
provider: {
|
||||
provide: PluginFactory.name,
|
||||
useValue: pluginInstance,
|
||||
},
|
||||
pluginInstance,
|
||||
};
|
||||
}
|
||||
private static plugins: Promise<ApiNestPluginDefinition[]> | undefined;
|
||||
|
||||
static async getPlugins() {
|
||||
/** All api plugins must be npm packages whose name starts with this prefix */
|
||||
const pluginPrefix = 'unraid-api-plugin-';
|
||||
// All api plugins must be installed as dependencies of the unraid-api package
|
||||
/** list of npm packages that are unraid-api plugins */
|
||||
const plugins = getPackageDependencies()?.filter((pkgName) => pkgName.startsWith(pluginPrefix));
|
||||
if (!plugins) {
|
||||
PluginService.logger.warn('Could not load dependencies from the Unraid-API package.json');
|
||||
// Fail silently: Return the module without plugins
|
||||
return [];
|
||||
}
|
||||
PluginService.plugins ??= PluginService.importPlugins();
|
||||
return PluginService.plugins;
|
||||
}
|
||||
|
||||
const failedPlugins: string[] = [];
|
||||
const { data: pluginProviders } = await batchProcess(plugins, async (pluginPackage) => {
|
||||
static async getGraphQLSchemas() {
|
||||
const plugins = (await PluginService.getPlugins()).filter(
|
||||
(plugin): plugin is SetRequired<ApiNestPluginDefinition, 'graphqlSchemaExtension'> =>
|
||||
plugin.graphqlSchemaExtension !== undefined
|
||||
);
|
||||
const { data: schemas } = await batchProcess(plugins, async (plugin) => {
|
||||
try {
|
||||
return await PluginService.getPluginFromPackage(pluginPackage);
|
||||
const schema = await plugin.graphqlSchemaExtension();
|
||||
// Validate schema by parsing it - this will throw if invalid
|
||||
parse(schema);
|
||||
return schema;
|
||||
} catch (error) {
|
||||
failedPlugins.push(pluginPackage);
|
||||
PluginService.logger.warn(error);
|
||||
// we can safely assert ApiModule's presence since we validate the plugin schema upon importing it.
|
||||
// ApiModule must be defined when graphqlSchemaExtension is defined.
|
||||
PluginService.logger.error(
|
||||
`Error parsing GraphQL schema from ${plugin.ApiModule!.name}: ${JSON.stringify(error, null, 2)}`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
if (failedPlugins.length > 0) {
|
||||
PluginService.logger.warn(
|
||||
`${failedPlugins.length} plugins failed to load. Ignoring them: ${failedPlugins.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
return pluginProviders;
|
||||
return schemas;
|
||||
}
|
||||
|
||||
async getGraphQLConfiguration() {
|
||||
await this.loadPlugins();
|
||||
const plugins = this.plugins;
|
||||
|
||||
let combinedResolvers = {};
|
||||
const typeDefs: string[] = [];
|
||||
|
||||
for (const plugin of plugins) {
|
||||
if (plugin.registerGraphQLResolvers) {
|
||||
const pluginResolvers = await plugin.registerGraphQLResolvers();
|
||||
combinedResolvers = {
|
||||
...combinedResolvers,
|
||||
...pluginResolvers,
|
||||
};
|
||||
}
|
||||
|
||||
if (plugin.registerGraphQLTypeDefs) {
|
||||
const pluginTypeDefs = await plugin.registerGraphQLTypeDefs();
|
||||
try {
|
||||
// Validate schema by parsing it - this will throw if invalid
|
||||
parse(pluginTypeDefs);
|
||||
typeDefs.push(pluginTypeDefs);
|
||||
} catch (error) {
|
||||
const errorMessage = `Plugin ${plugin.name} returned an unusable GraphQL type definition: ${JSON.stringify(
|
||||
pluginTypeDefs
|
||||
)}`;
|
||||
PluginService.logger.warn(errorMessage);
|
||||
}
|
||||
}
|
||||
private static async importPlugins() {
|
||||
if (PluginService.plugins) {
|
||||
return PluginService.plugins;
|
||||
}
|
||||
const pluginPackages = await PluginService.listPlugins();
|
||||
const plugins = await batchProcess(pluginPackages, async ([pkgName]) => {
|
||||
try {
|
||||
const plugin = await import(/* @vite-ignore */ pkgName);
|
||||
return apiNestPluginSchema.parse(plugin);
|
||||
} catch (error) {
|
||||
PluginService.logger.error(`Plugin from ${pkgName} is invalid`, error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
resolvers: combinedResolvers,
|
||||
typeDefs: typeDefs.join('\n'),
|
||||
};
|
||||
if (plugins.errorOccured) {
|
||||
PluginService.logger.warn(`Failed to load ${plugins.errors.length} plugins. Ignoring them.`);
|
||||
}
|
||||
return plugins.data;
|
||||
}
|
||||
|
||||
async getRESTConfiguration() {
|
||||
await this.loadPlugins();
|
||||
const controllers: Type<any>[] = [];
|
||||
const routes: Record<string, any>[] = [];
|
||||
|
||||
for (const plugin of this.plugins) {
|
||||
if (plugin.registerRESTControllers) {
|
||||
const pluginControllers = await plugin.registerRESTControllers();
|
||||
controllers.push(...pluginControllers);
|
||||
}
|
||||
|
||||
if (plugin.registerRESTRoutes) {
|
||||
const pluginRoutes = await plugin.registerRESTRoutes();
|
||||
routes.push(...pluginRoutes);
|
||||
}
|
||||
private static async listPlugins(): Promise<[string, string][]> {
|
||||
/** All api plugins must be npm packages whose name starts with this prefix */
|
||||
const pluginPrefix = 'unraid-api-plugin-';
|
||||
// All api plugins must be installed as dependencies of the unraid-api package
|
||||
const { dependencies } = getPackageJson();
|
||||
if (!dependencies) {
|
||||
PluginService.logger.warn('Unraid-API dependencies not found; skipping plugins.');
|
||||
return [];
|
||||
}
|
||||
|
||||
return {
|
||||
controllers,
|
||||
routes,
|
||||
};
|
||||
}
|
||||
|
||||
async getServices() {
|
||||
await this.loadPlugins();
|
||||
const services: Type<any>[] = [];
|
||||
|
||||
for (const plugin of this.plugins) {
|
||||
if (plugin.registerServices) {
|
||||
const pluginServices = await plugin.registerServices();
|
||||
services.push(...pluginServices);
|
||||
}
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
async getCronJobs() {
|
||||
await this.loadPlugins();
|
||||
const cronJobs: Record<string, any>[] = [];
|
||||
|
||||
for (const plugin of this.plugins) {
|
||||
if (plugin.registerCronJobs) {
|
||||
const pluginCronJobs = await plugin.registerCronJobs();
|
||||
cronJobs.push(...pluginCronJobs);
|
||||
}
|
||||
}
|
||||
|
||||
return cronJobs;
|
||||
const plugins = Object.entries(dependencies).filter((entry): entry is [string, string] => {
|
||||
const [pkgName, version] = entry;
|
||||
return pkgName.startsWith(pluginPrefix) && typeof version === 'string';
|
||||
});
|
||||
return plugins;
|
||||
}
|
||||
}
|
||||
|
||||
47
packages/unraid-api-plugin-connect/index.ts
Normal file
47
packages/unraid-api-plugin-connect/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Module, Logger, Inject } from "@nestjs/common";
|
||||
import { ConfigModule, ConfigService, registerAs } from "@nestjs/config";
|
||||
import { Resolver, Query } from "@nestjs/graphql";
|
||||
|
||||
export const adapter = 'nestjs';
|
||||
|
||||
export const graphqlSchemaExtension = async () => `
|
||||
type Query {
|
||||
health: String
|
||||
}
|
||||
`;
|
||||
|
||||
@Resolver()
|
||||
export class HealthResolver {
|
||||
@Query(() => String)
|
||||
health() {
|
||||
// You can replace the return value with your actual health check logic
|
||||
return 'I am healthy!';
|
||||
}
|
||||
}
|
||||
|
||||
const config = registerAs("connect", () => ({
|
||||
demo: true,
|
||||
}));
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule.forFeature(config)],
|
||||
providers: [HealthResolver],
|
||||
})
|
||||
class ConnectPluginModule {
|
||||
logger = new Logger(ConnectPluginModule.name);
|
||||
private readonly configService: ConfigService;
|
||||
|
||||
/**
|
||||
* @param {ConfigService} configService
|
||||
*/
|
||||
constructor(@Inject(ConfigService) configService: ConfigService) {
|
||||
this.configService = configService;
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
this.logger.log("Connect plugin initialized");
|
||||
console.log("Connect plugin initialized", this.configService.get('connect'));
|
||||
}
|
||||
}
|
||||
|
||||
export const ApiModule = ConnectPluginModule;
|
||||
33
packages/unraid-api-plugin-connect/package.json
Normal file
33
packages/unraid-api-plugin-connect/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "unraid-api-plugin-connect",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"build": "tsc",
|
||||
"prepare": "npm run build"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Lime Technology, Inc. <unraid.net>",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"description": "Example Health plugin for Unraid API",
|
||||
"devDependencies": {
|
||||
"@nestjs/common": "^11.0.11",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.11",
|
||||
"@nestjs/graphql": "^13.0.3",
|
||||
"nest-authz": "^2.14.0",
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^11.0.11",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.11",
|
||||
"@nestjs/graphql": "^13.0.3",
|
||||
"nest-authz": "^2.14.0"
|
||||
}
|
||||
}
|
||||
18
packages/unraid-api-plugin-connect/tsconfig.json
Normal file
18
packages/unraid-api-plugin-connect/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["index.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
export default ({ store, logger }) => ({
|
||||
_type: "UnraidApiPlugin",
|
||||
name: "HealthPlugin",
|
||||
description: "Health plugin",
|
||||
|
||||
commands: [],
|
||||
|
||||
async registerGraphQLResolvers() {
|
||||
return {
|
||||
Query: {
|
||||
health: () => {
|
||||
logger.log("Pinged health");
|
||||
return "OK";
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
async registerGraphQLTypeDefs() {
|
||||
return `
|
||||
type Query {
|
||||
health: String
|
||||
}
|
||||
`;
|
||||
},
|
||||
async onModuleInit() {
|
||||
logger.log("Health plugin initialized");
|
||||
},
|
||||
|
||||
async onModuleDestroy() {
|
||||
logger.log("Health plugin destroyed");
|
||||
},
|
||||
});
|
||||
31
packages/unraid-api-plugin-health/index.ts
Normal file
31
packages/unraid-api-plugin-health/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Module, Logger } from "@nestjs/common";
|
||||
import { Resolver, Query } from "@nestjs/graphql";
|
||||
|
||||
export const adapter = 'nestjs';
|
||||
|
||||
export const graphqlSchemaExtension = async () => `
|
||||
type Query {
|
||||
health: String
|
||||
}
|
||||
`;
|
||||
|
||||
@Resolver()
|
||||
export class HealthResolver {
|
||||
@Query(() => String)
|
||||
health() {
|
||||
return 'OK';
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
providers: [HealthResolver],
|
||||
})
|
||||
class HealthPlugin {
|
||||
logger = new Logger(HealthPlugin.name);
|
||||
|
||||
onModuleInit() {
|
||||
this.logger.log("Health plugin initialized");
|
||||
}
|
||||
}
|
||||
|
||||
export const ApiModule = HealthPlugin;
|
||||
@@ -1,13 +1,33 @@
|
||||
{
|
||||
"name": "unraid-api-plugin-health",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"build": "tsc",
|
||||
"prepare": "npm run build"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Lime Technology, Inc. <unraid.net>",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"description": "Example Health plugin for Unraid API"
|
||||
"description": "Example Health plugin for Unraid API",
|
||||
"devDependencies": {
|
||||
"@nestjs/common": "^11.0.11",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.11",
|
||||
"@nestjs/graphql": "^13.0.3",
|
||||
"nest-authz": "^2.14.0",
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^11.0.11",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.11",
|
||||
"@nestjs/graphql": "^13.0.3",
|
||||
"nest-authz": "^2.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
18
packages/unraid-api-plugin-health/tsconfig.json
Normal file
18
packages/unraid-api-plugin-health/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["index.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
67
pnpm-lock.yaml
generated
67
pnpm-lock.yaml
generated
@@ -62,6 +62,9 @@ importers:
|
||||
'@nestjs/common':
|
||||
specifier: ^11.0.11
|
||||
version: 11.0.12(reflect-metadata@0.1.14)(rxjs@7.8.2)
|
||||
'@nestjs/config':
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2(@nestjs/common@11.0.12(reflect-metadata@0.1.14)(rxjs@7.8.2))(rxjs@7.8.2)
|
||||
'@nestjs/core':
|
||||
specifier: ^11.0.11
|
||||
version: 11.0.12(@nestjs/common@11.0.12(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2)
|
||||
@@ -460,7 +463,47 @@ importers:
|
||||
specifier: ^8.3.2
|
||||
version: 8.4.1
|
||||
|
||||
packages/unraid-api-plugin-health: {}
|
||||
packages/unraid-api-plugin-connect:
|
||||
devDependencies:
|
||||
'@nestjs/common':
|
||||
specifier: ^11.0.11
|
||||
version: 11.0.12(reflect-metadata@0.1.14)(rxjs@7.8.2)
|
||||
'@nestjs/config':
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2(@nestjs/common@11.0.12(reflect-metadata@0.1.14)(rxjs@7.8.2))(rxjs@7.8.2)
|
||||
'@nestjs/core':
|
||||
specifier: ^11.0.11
|
||||
version: 11.0.12(@nestjs/common@11.0.12(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2)
|
||||
'@nestjs/graphql':
|
||||
specifier: ^13.0.3
|
||||
version: 13.0.4(@nestjs/common@11.0.12(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.0.12(@nestjs/common@11.0.12(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2))(graphql@16.10.0)(reflect-metadata@0.1.14)(ts-morph@24.0.0)
|
||||
nest-authz:
|
||||
specifier: ^2.14.0
|
||||
version: 2.15.0(@nestjs/common@11.0.12(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.0.12(@nestjs/common@11.0.12(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2)
|
||||
typescript:
|
||||
specifier: ^5.8.2
|
||||
version: 5.8.2
|
||||
|
||||
packages/unraid-api-plugin-health:
|
||||
devDependencies:
|
||||
'@nestjs/common':
|
||||
specifier: ^11.0.11
|
||||
version: 11.0.12(reflect-metadata@0.1.14)(rxjs@7.8.2)
|
||||
'@nestjs/config':
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2(@nestjs/common@11.0.12(reflect-metadata@0.1.14)(rxjs@7.8.2))(rxjs@7.8.2)
|
||||
'@nestjs/core':
|
||||
specifier: ^11.0.11
|
||||
version: 11.0.12(@nestjs/common@11.0.12(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2)
|
||||
'@nestjs/graphql':
|
||||
specifier: ^13.0.3
|
||||
version: 13.0.4(@nestjs/common@11.0.12(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.0.12(@nestjs/common@11.0.12(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2))(graphql@16.10.0)(reflect-metadata@0.1.14)(ts-morph@24.0.0)
|
||||
nest-authz:
|
||||
specifier: ^2.14.0
|
||||
version: 2.15.0(@nestjs/common@11.0.12(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.0.12(@nestjs/common@11.0.12(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2)
|
||||
typescript:
|
||||
specifier: ^5.8.2
|
||||
version: 5.8.2
|
||||
|
||||
plugin:
|
||||
dependencies:
|
||||
@@ -2530,6 +2573,12 @@ packages:
|
||||
class-validator:
|
||||
optional: true
|
||||
|
||||
'@nestjs/config@4.0.2':
|
||||
resolution: {integrity: sha512-McMW6EXtpc8+CwTUwFdg6h7dYcBUpH5iUILCclAsa+MbCEvC9ZKu4dCHRlJqALuhjLw97pbQu62l4+wRwGeZqA==}
|
||||
peerDependencies:
|
||||
'@nestjs/common': ^10.0.0 || ^11.0.0
|
||||
rxjs: ^7.1.0
|
||||
|
||||
'@nestjs/core@11.0.12':
|
||||
resolution: {integrity: sha512-micQrbh9iL0PuYVx2vsUojuNmMUyqoMCuj7eGAUhvjiZUh4DBLPdxYmJEayCT/equHSiw9vNC95Vm0JigVZ44g==}
|
||||
engines: {node: '>= 20'}
|
||||
@@ -5939,6 +5988,10 @@ packages:
|
||||
resolution: {integrity: sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
dotenv-expand@12.0.1:
|
||||
resolution: {integrity: sha512-LaKRbou8gt0RNID/9RoI+J2rvXsBRPMV7p+ElHlPhcSARbCPDYcYG2s1TIzAfWv4YSgyY5taidWzzs31lNV3yQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dotenv@16.4.7:
|
||||
resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -13738,6 +13791,14 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
uid: 2.0.2
|
||||
|
||||
'@nestjs/config@4.0.2(@nestjs/common@11.0.12(reflect-metadata@0.1.14)(rxjs@7.8.2))(rxjs@7.8.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.0.12(reflect-metadata@0.1.14)(rxjs@7.8.2)
|
||||
dotenv: 16.4.7
|
||||
dotenv-expand: 12.0.1
|
||||
lodash: 4.17.21
|
||||
rxjs: 7.8.2
|
||||
|
||||
'@nestjs/core@11.0.12(@nestjs/common@11.0.12(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.0.12(reflect-metadata@0.1.14)(rxjs@7.8.2)
|
||||
@@ -17749,6 +17810,10 @@ snapshots:
|
||||
dependencies:
|
||||
type-fest: 4.38.0
|
||||
|
||||
dotenv-expand@12.0.1:
|
||||
dependencies:
|
||||
dotenv: 16.4.7
|
||||
|
||||
dotenv@16.4.7: {}
|
||||
|
||||
dotgitignore@2.1.0:
|
||||
|
||||
Reference in New Issue
Block a user