Skip to content

Load files from subdirectories #4

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Feb 19, 2023
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
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ Before:
import { Module } from '@nestjs/common';

@Module({
imports: [TypeOrmModule.forFeature([Admin])],
controllers: [AuthController],
providers: [AuthService, AdminGuard, UserMiddleware, AdminService],
imports: [TypeOrmModule.forFeature([Admin])],
exports: [AuthService],
})
export class AuthModule {}
Expand All @@ -64,3 +64,23 @@ import { AutoloadModule } from 'nestjs-autoloader';
})
export class AuthModule {}
```

## Nested Module Directories

The autoloader is designed to work with nested module directories.
For example:

```
parent/
├── parent.module.ts
├── parent.service.ts
└── sub/
├── sub.module.ts
└── sub.service.ts
```

This will load the `parent.service.ts` for the `parent` module,
but not the `sub.service.ts`.
The autoloader recognises nested modules by looking at `*.module.ts` files.
If a directory contains a file with that name,
it will exclude this directory from autoloading for the containing module.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
},
"scripts": {
"prepack": "npm run build",
"build": "tsc",
"build": "tsc -p tsconfig.build.json",
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0",
"format": "npm run lint -- --fix",
"test": "jest"
Expand Down
77 changes: 77 additions & 0 deletions src/AutoloadModule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Logger, Module, ModuleMetadata, Provider, Type } from '@nestjs/common';
import {
CONTROLLER_WATERMARK,
INJECTABLE_WATERMARK,
} from '@nestjs/common/constants';
import { combine } from './combine';
import { listFiles } from './listFiles';
import { isScript } from './isScript';

const logger = new Logger('AutoloadModule');

export function AutoloadModule(
dirName: string,
metadata?: ModuleMetadata
): ClassDecorator {
return (target) => {
logger.verbose(`Autoloading module: ${dirName}`);
const loaded = loadScripts(dirName);
const combinedMeta: ModuleMetadata = {
...metadata,
controllers: combine(metadata?.controllers, loaded.controllers),
providers: combine(metadata?.providers, loaded.providers),
};
Module(combinedMeta)(target);
};
}

interface LoadResult {
controllers: Type[];
providers: Provider[];
}

function loadScripts(dirName: string): LoadResult {
const scripts = listFiles(dirName).filter(isScript);

const result: LoadResult = {
controllers: [],
providers: [],
};

for (const script of scripts) {
logger.verbose(`Autoloading file: ${script}`);

// Here we have to use require, to get the exports into a variable.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const reqVals = Object.values(require(script));

for (const check of reqVals) {
if (typeof check === 'function') {
if (isController(check)) {
logger.verbose(`Found controller: ${check.name}`);
result.controllers.push(check);
}

if (isProvider(check)) {
logger.verbose(`Found provider: ${check.name}`);
result.providers.push(check);
}
}
}
}

return result;
}

// For both of these functions,
// we specifically want to use the Object type because it accepts almost anything.

// eslint-disable-next-line @typescript-eslint/ban-types
function isController(fn: Object): fn is Type {
return Reflect.hasMetadata(CONTROLLER_WATERMARK, fn);
}

// eslint-disable-next-line @typescript-eslint/ban-types
function isProvider(fn: Object): fn is Provider {
return Reflect.hasMetadata(INJECTABLE_WATERMARK, fn);
}
3 changes: 3 additions & 0 deletions src/combine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function combine<T>(left: T[] | undefined, right: T[]): T[] {
return [...(left || []), ...right];
}
90 changes: 1 addition & 89 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,89 +1 @@
import { Logger, Module, ModuleMetadata, Provider, Type } from '@nestjs/common';
import {
CONTROLLER_WATERMARK,
INJECTABLE_WATERMARK,
} from '@nestjs/common/constants';
import fs from 'fs';

const logger = new Logger('AutoloadModule');

export function AutoloadModule(
dirName: string,
metadata?: ModuleMetadata
): ClassDecorator {
return (target) => {
logger.verbose(`Autoloading module: ${dirName}`);
const loaded = loadFiles(dirName);
const combinedMeta: ModuleMetadata = {
...metadata,
controllers: combine(metadata?.controllers, loaded.controllers),
providers: combine(metadata?.providers, loaded.providers),
};
Module(combinedMeta)(target);
};
}

interface LoadResult {
controllers: Type[];
providers: Provider[];
}

function loadFiles(dirName: string): LoadResult {
const checkFiles = fs
.readdirSync(dirName)
.filter(
(f) => (f.endsWith('.js') || f.endsWith('.ts')) && !f.endsWith('.d.ts')
)
.map((f) => `${dirName}/${f}`);

const result: LoadResult = {
controllers: [],
providers: [],
};

for (const file of checkFiles) {
logger.verbose(`Autoloading file: ${file}`);

// Here we have to use require, to get the exports into a variable.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const reqVals = Object.values(require(file));

for (const check of reqVals) {
if (typeof check === 'function') {
const controller = checkController(check);
if (controller) {
result.controllers.push(controller);
}

const provider = checkProvider(check);
if (provider) {
result.providers.push(provider);
}
}
}
}

return result;
}

// For both of these functions, we specifically want to use the Function type because it accepts any function-like.

// eslint-disable-next-line @typescript-eslint/ban-types
function checkController(fn: Function): Type | undefined {
if (Reflect.hasMetadata(CONTROLLER_WATERMARK, fn)) {
logger.verbose(`Found controller: ${fn.name}`);
return fn as Type;
}
}

// eslint-disable-next-line @typescript-eslint/ban-types
function checkProvider(fn: Function): Provider | undefined {
if (Reflect.hasMetadata(INJECTABLE_WATERMARK, fn)) {
logger.verbose(`Found provider: ${fn.name}`);
return fn as Provider;
}
}

function combine<T>(left: T[] | undefined, right: T[]): T[] {
return [...(left || []), ...right];
}
export * from './AutoloadModule';
13 changes: 13 additions & 0 deletions src/isScript.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export function isScript(name: string): boolean {
return (
// Include js and ts files.
(name.endsWith('.js') || name.endsWith('.ts')) &&
// Exclude type mappings.
!name.endsWith('.d.ts') &&
// Exclude test-related files.
!name.endsWith('.test.ts') &&
!name.endsWith('.spec.ts') &&
!name.endsWith('.test.js') &&
!name.endsWith('.spec.js')
);
}
44 changes: 44 additions & 0 deletions src/listFiles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import fs from 'fs';
import path from 'path';

export function listFiles(dirName: string): string[] {
return listFilesRecursive(dirName, true);
}

function listFilesRecursive(dirName: string, isRootModule: boolean): string[] {
const entries = fs.readdirSync(dirName, { withFileTypes: true });
const files: string[] = [];
const dirs: string[] = [];

let isSubModuleDir = false;

entries.forEach((e) => {
// Get the full path to the entry.
const p = path.join(dirName, e.name);

// Split the entry into one of the lists: file or directory.
if (e.isDirectory()) {
dirs.push(p);
} else {
files.push(p);

// Check if this directory is a separate submodule.
if (
!isRootModule &&
(p.endsWith('.module.ts') || p.endsWith('.module.js'))
) {
isSubModuleDir = true;
}
}
});

// If this directory is a submodule, we want to ignore it from autoloading.
if (isSubModuleDir) {
return [];
}

// Recurse into all subdirectories.
dirs.forEach((d) => files.push(...listFilesRecursive(d, false)));

return files;
}
30 changes: 22 additions & 8 deletions test/AutoloadModule.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,33 @@
import { AutoloadModule } from '../src';
import { TestController } from './test.controller';
import { TestProvider } from './test.provider';
import { TestController } from './module/test.controller';
import { TestProvider } from './module/test.provider';
import { SubdirController } from './module/subdir/subdir.controller';
import { SubdirProvider } from './module/subdir/subdir.provider';
import path from 'path';
import { IncludeProvider } from './module-with-submodule/include.provider';

/*
The way to check if items are properly loaded is to check the class metadata.
The `@Module` decorator set each passed object property as a metadata property.
*/

describe('AutoloadModule', () => {
it('loads controllers and providers', () => {
@AutoloadModule(__dirname)
@AutoloadModule(path.join(__dirname, 'module'))
class TestModule {}

// The way to check if items are properly loaded, is to check the class metadata.
// The `@Module` decorator set each passed object property as a metadata property.

const controllers = Reflect.getOwnMetadata('controllers', TestModule);
expect(controllers).toStrictEqual([TestController]);
expect(controllers).toStrictEqual([TestController, SubdirController]);

const providers = Reflect.getOwnMetadata('providers', TestModule);
expect(providers).toStrictEqual([TestProvider, SubdirProvider]);
});

it('does not load items from submodules', () => {
@AutoloadModule(path.join(__dirname, 'module-with-submodule'))
class TestModule {}

const providers = Reflect.getOwnMetadata('providers', TestModule);
expect(providers).toStrictEqual([TestProvider]);
expect(providers).toStrictEqual([IncludeProvider]);
});
});
18 changes: 18 additions & 0 deletions test/isScript.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { isScript } from '../src/isScript';

describe('isScript', () => {
it.each`
extension | result
${'file.js'} | ${true}
${'file.ts'} | ${true}
${'file.service.ts'} | ${true}
${'file.service.js'} | ${true}
${'file.d.ts'} | ${false}
${'file.test.ts'} | ${false}
${'file.test.js'} | ${false}
${'file.spec.ts'} | ${false}
${'file.spec.js'} | ${false}
`('returns $result when given $extension', ({ extension, result }) => {
expect(isScript(extension)).toBe(result);
});
});
Empty file.
Empty file added test/list/list-sub/two.txt
Empty file.
Empty file added test/list/one.txt
Empty file.
20 changes: 20 additions & 0 deletions test/listFiles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { listFiles } from '../src/listFiles';
import path from 'path';

describe('listFiles', () => {
it('returns a list of all files, from all subdirectories', () => {
const root = path.join(__dirname, 'list');
const paths = listFiles(root);
expect(paths).toStrictEqual([
path.join(root, 'one.txt'),
path.join(root, 'list-sub', 'two.txt'),
path.join(root, 'list-sub', 'list-sub-sub', 'three.txt'),
]);
});

it('excludes directories containing a module file', () => {
const root = path.join(__dirname, 'module-with-submodule');
const paths = listFiles(root);
expect(paths).toStrictEqual([path.join(root, 'include.provider.ts')]);
});
});
4 changes: 4 additions & 0 deletions test/module-with-submodule/include.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { Injectable } from '@nestjs/common';

@Injectable()
export class IncludeProvider {}
4 changes: 4 additions & 0 deletions test/module-with-submodule/submodule/no-include.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { Injectable } from '@nestjs/common';

@Injectable()
export class NoIncludeProvider {}
1 change: 1 addition & 0 deletions test/module-with-submodule/submodule/sub.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export class SubModule {}
4 changes: 4 additions & 0 deletions test/module/subdir/subdir.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { Controller } from '@nestjs/common';

@Controller()
export class SubdirController {}
4 changes: 4 additions & 0 deletions test/module/subdir/subdir.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { Injectable } from '@nestjs/common';

@Injectable()
export class SubdirProvider {}
File renamed without changes.
File renamed without changes.
6 changes: 6 additions & 0 deletions tsconfig.build.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.json",
"include": [
"src/**/*"
]
}
4 changes: 2 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@
"declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist", /* Specify an output folder for all emitted files. */
"outDir": "./dist", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
Expand Down