Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 1 addition & 10 deletions api/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 1 addition & 1 deletion api/src/unraid-api/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/u
},
]),
UnraidFileModifierModule,
PluginModule.registerPlugins(),
PluginModule.register(),
],
controllers: [],
providers: [
Expand Down
47 changes: 4 additions & 43 deletions api/src/unraid-api/cli/cli.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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 {}
12 changes: 3 additions & 9 deletions api/src/unraid-api/graph/graph.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,35 +15,29 @@ 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({
imports: [
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,
Long: GraphQLLong,
Port: PortResolver,
URL: URLResolver,
UUID: UUIDResolver,
...plugins.resolvers,
};

return {
introspection: getters.config()?.local?.sandbox === 'yes',
playground: false,
Expand Down
75 changes: 40 additions & 35 deletions api/src/unraid-api/plugin/plugin.interface.ts
Original file line number Diff line number Diff line change
@@ -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()));
Expand All @@ -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>;
35 changes: 31 additions & 4 deletions api/src/unraid-api/plugin/plugin.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
};
}
}
Loading
Loading