Skip to content

Commit d337aec

Browse files
committed
Add Extensions module and DTOs for extension management and validation
1 parent a320f58 commit d337aec

File tree

6 files changed

+182
-0
lines changed

6 files changed

+182
-0
lines changed

extensions/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*
2+
!.gitignore

src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
2525
import { ShutdownObserver } from './_common/observers/shutdown.observer';
2626
import { ScheduleModule } from '@nestjs/schedule';
2727
import { HttpModule } from '@nestjs/axios';
28+
import { ExtensionsModule } from './extensions/extensions.module';
2829

2930
@Module({
3031
imports: [
@@ -133,6 +134,7 @@ import { HttpModule } from '@nestjs/axios';
133134
ManagementModule.register(),
134135
SettingsModule.register(),
135136
MigrationsModule.register(),
137+
ExtensionsModule.register(),
136138
],
137139
controllers: [AppController],
138140
providers: [
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Type } from 'class-transformer'
2+
import { IsDefined, IsEnum, IsOptional, IsString, ValidateNested } from 'class-validator'
3+
4+
export class ExtensionSettingsAppServiceMetadataV1 {
5+
@IsDefined()
6+
@IsString()
7+
public target: string
8+
9+
@IsOptional()
10+
@IsString()
11+
public mainModule: string = 'ExtensionModule'
12+
}
13+
14+
export class ExtensionSettingsMetadataV1 {
15+
@ValidateNested()
16+
@Type(() => ExtensionSettingsAppServiceMetadataV1)
17+
public app: ExtensionSettingsAppServiceMetadataV1
18+
19+
@ValidateNested()
20+
@Type(() => ExtensionSettingsAppServiceMetadataV1)
21+
public service: ExtensionSettingsAppServiceMetadataV1
22+
}
23+
24+
export class ExtensionInformationMetadataV1 {
25+
@IsString()
26+
public name: string
27+
28+
@IsString()
29+
public author: string
30+
31+
@IsString()
32+
public version: string
33+
}
34+
35+
export class ExtensionFileV1 {
36+
@IsDefined()
37+
@IsEnum(['1'])
38+
public version: string
39+
40+
@IsDefined()
41+
@ValidateNested()
42+
@Type(() => ExtensionInformationMetadataV1)
43+
public information: ExtensionInformationMetadataV1
44+
45+
@IsDefined()
46+
@ValidateNested()
47+
@Type(() => ExtensionSettingsMetadataV1)
48+
public settings: ExtensionSettingsMetadataV1
49+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Type } from 'class-transformer'
2+
import { IsBoolean, IsEnum, IsString, ValidateNested } from 'class-validator'
3+
4+
export class ExtensionsListV1 {
5+
@IsString()
6+
public path: string
7+
8+
@IsBoolean()
9+
public enabled: boolean
10+
}
11+
12+
export class ExtensionsFileV1 {
13+
@IsEnum(['1'])
14+
public version: string
15+
16+
@ValidateNested({ each: true })
17+
@Type(() => ExtensionsListV1)
18+
public list: ExtensionsListV1[]
19+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { DynamicModule, Logger, Module, OnModuleInit } from '@nestjs/common'
2+
import { RouterModule } from '@nestjs/core'
3+
import serviceSetup from '~/extensions/extensions.service.setup'
4+
5+
@Module({
6+
imports: [],
7+
})
8+
export class ExtensionsModule implements OnModuleInit {
9+
public async onModuleInit(): Promise<void> {
10+
Logger.log('All extensions is initialized', 'ExtensionsModule')
11+
}
12+
13+
public static async register(): Promise<DynamicModule> {
14+
Logger.debug('Registering extensions', 'ExtensionsModule')
15+
const modules = await serviceSetup()
16+
17+
return {
18+
module: this,
19+
imports: [
20+
...modules,
21+
RouterModule.register([
22+
{
23+
path: 'extensions',
24+
children: [...Reflect.getMetadata('imports', this)],
25+
},
26+
]),
27+
],
28+
}
29+
}
30+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { existsSync, readFileSync } from 'fs'
2+
import { dirname, join } from 'path'
3+
import { parse } from 'yaml'
4+
import { ExtensionFileV1 } from './_dto/extension.dto'
5+
import { ExtensionsFileV1, ExtensionsListV1 } from './_dto/extensions.dto'
6+
import { plainToInstance } from 'class-transformer'
7+
import { validateOrReject } from 'class-validator'
8+
import * as process from 'process'
9+
import { DynamicModule, Logger } from '@nestjs/common'
10+
11+
const serviceList: DynamicModule[] = []
12+
13+
export const EXTENSION_FILE_INFO = 'extension.yml'
14+
export const EXTENSIONS_FILE_PATH = join(process.cwd(), '/extensions/list.yml')
15+
16+
export async function parseExtensionsList(): Promise<ExtensionsListV1[]> {
17+
const data = readFileSync(EXTENSIONS_FILE_PATH, 'utf8')
18+
const yml = parse(data)
19+
const schema = plainToInstance(ExtensionsFileV1, yml)
20+
try {
21+
await validateOrReject(schema, {
22+
whitelist: true,
23+
})
24+
} catch (errors) {
25+
const err = new Error(`Invalid extensions`)
26+
err.message = errors.map((e) => e.toString()).join(', ') //TODO: improve error message
27+
throw err
28+
}
29+
30+
return yml.list
31+
}
32+
33+
export async function extensionParseFile(path: string): Promise<ExtensionFileV1> {
34+
Logger.log('Extension file found, validating...', 'extensionParseFile')
35+
const data = readFileSync(`${path}/${EXTENSION_FILE_INFO}`, 'utf8')
36+
const yml = parse(data)
37+
return plainToInstance(ExtensionFileV1, yml)
38+
}
39+
40+
export default async function (): Promise<DynamicModule[]> {
41+
try {
42+
if (existsSync(EXTENSIONS_FILE_PATH)) {
43+
Logger.log('Extensions file found, validating...', 'parsingAppExtensions')
44+
const list = await parseExtensionsList()
45+
46+
for (const extension of list) {
47+
if (!extension.enabled) {
48+
Logger.log(`Extension ${extension.path} is disabled`, 'ExtensionServiceSetup')
49+
continue
50+
}
51+
const extensionPath = `${process.cwd()}/extensions/${extension.path}`
52+
const extensionFile = await extensionParseFile(extensionPath)
53+
if (!extensionFile.settings.service.target) {
54+
Logger.warn(`Extension ${extensionFile.information.name} has no service target`, 'ExtensionServiceSetup')
55+
continue
56+
}
57+
const extensionServiceTarget = `${extensionPath}/${extensionFile.settings.service.target}`
58+
await import(extensionServiceTarget)
59+
.then((module) => {
60+
if (module[extensionFile.settings.service.mainModule]) {
61+
serviceList.push(module[extensionFile.settings.service.mainModule])
62+
Logger.log(`Extension ${extensionFile.information.name} is enabled`, 'ExtensionServiceSetup')
63+
return
64+
}
65+
Logger.warn(`Extension <${extensionFile.information.name}> module <${extensionFile.settings.service.mainModule}> has no main module`, 'ExtensionServiceSetup')
66+
})
67+
.catch((err) => {
68+
Logger.error(`Extension <${extensionFile.information.name}> located in <${extensionServiceTarget}> failed to load`, 'ExtensionServiceSetup')
69+
console.error(err)
70+
console.trace(err.stack)
71+
})
72+
}
73+
}
74+
return serviceList
75+
} catch (err) {
76+
Logger.error('Failed to load extensions', 'ExtensionServiceSetup')
77+
console.error(err)
78+
process.exit(1)
79+
}
80+
}

0 commit comments

Comments
 (0)