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
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {RestApplication} from '@loopback/rest';
import {expect, sinon} from '@loopback/testlab';
import {CoreControllerBooter} from '../../../booters/core-controller.booter';

describe('CoreControllerBooter', () => {
let app: RestApplication;

class DummyController {}

class TestBooter extends CoreControllerBooter {
constructor(application: RestApplication) {
super(application);
}

async load(): Promise<void> {
this.classes = [DummyController];
this.classes.forEach(cls => this.bindController(cls));
}
}

beforeEach(() => {
app = new RestApplication();
});

afterEach(() => {
sinon.restore();
});

it('should bind discovered controllers', async () => {
const controllerSpy = sinon.spy(app, 'controller');

const booter = new TestBooter(app);
await booter.load();

sinon.assert.calledWith(controllerSpy, DummyController);
expect(app.isBound('controllers.DummyController')).to.be.true();
});

it('should skip controller binding if already bound', async () => {
app.controller(DummyController); // Pre-bind it

const spy = sinon.spy(app, 'controller');
const booter = new TestBooter(app);
await booter.load();

sinon.assert.notCalled(spy);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {Component, Context, MetadataInspector} from '@loopback/core';
import {RestApplication} from '@loopback/rest';
import {sinon} from '@loopback/testlab';
import {CoreModelBooter} from '../../../booters/core-model.booter';
import {OVERRIDE_MODEL_SCHEMA_KEY} from '../../../build-schema';

describe('CoreModelBooter', () => {
let app: RestApplication;
let ctx: Context;

class DummyBooter extends CoreModelBooter {
constructor(context: Context) {
super(context);
}

// Override discover to prevent requiring real files
async discover(): Promise<void> {
// simulate discovery of classes
this.classes = [class MyComponent {}];
}
}

beforeEach(() => {
app = new RestApplication();
ctx = new Context(app);
});

afterEach(() => {
sinon.restore();
});

it('should skip load when component has no models', async () => {
class NoModelComponent implements Component {}

ctx.bind('components.MyComponent').to(new NoModelComponent());

const defineSpy = sinon.spy(MetadataInspector, 'defineMetadata');

const booter = new DummyBooter(ctx);
await booter.discover();
await booter.load();

sinon.assert.notCalled(defineSpy);
});

it('should load models and define metadata', async () => {
class MyModel {}
class OverriddenModel {}

class MyComponent implements Component {
models = [MyModel];
}

ctx.bind('components.MyComponent').to(new MyComponent());
ctx.bind('models.MyModel').to(OverriddenModel);

const spy = sinon.spy(MetadataInspector, 'defineMetadata');

const booter = new DummyBooter(ctx);
await booter.discover(); // sets MyComponent
await booter.load();

sinon.assert.calledOnceWithExactly(
spy,
OVERRIDE_MODEL_SCHEMA_KEY,
OverriddenModel,
MyModel,
);
});
});
96 changes: 96 additions & 0 deletions packages/core/src/booters/base.booter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import {
ArtifactOptions,
Booter,
discoverFiles,
loadClassesFromFiles,
} from '@loopback/boot';
import {Constructor} from 'loopback4-soft-delete';

export class BaseBooter implements Booter {
/**
* Options being used by the Booter.
*/
options: ArtifactOptions;
/**
* Project root relative to which all other paths are resolved
*/
projectRoot: string;
/**
* Relative paths of directories to be searched
*/
dirs: string[];
/**
* File extensions to be searched
*/
extensions: string[];
/**
* `glob` pattern to match artifact paths
*/
glob: string;

/**
* List of files discovered by the Booter that matched artifact requirements
*/
discovered: string[];
/**
* List of exported classes discovered in the files
*/
classes: Constructor<{}>[];

/**
* Get the name of the artifact loaded by this booter, e.g. "Controller".
* Subclasses can override the default logic based on the class name.
*/
get artifactName(): string {
return this.constructor.name.replace(/Booter$/, '');
}

/**
* Configure the Booter by initializing the 'dirs', 'extensions' and 'glob'
* properties.
*
* NOTE: All properties are configured even if all aren't used.
*/
async configure() {
this.dirs = this.normalizeToArray(this.options.dirs);
this.extensions = this.normalizeToArray(this.options.extensions);

const joinedDirs = this.formatDirs(this.dirs);
const joinedExts = `@(${this.extensions.join('|')})`;

this.glob =
this.options.glob ??
`/${joinedDirs}/${this.options.nested ? '**/*' : '*'}${joinedExts}`;
}

private normalizeToArray<T>(value: T | T[] | undefined): T[] {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}

private formatDirs(dirs: string[]): string {
if (dirs.length > 1) return `{${dirs.join(',')}}`;
return dirs.join(',');
}

/**
* Discover files based on the 'glob' property relative to the 'projectRoot'.
* Discovered artifact files matching the pattern are saved to the
* 'discovered' property.
*/
async discover() {
this.discovered = await discoverFiles(this.glob, this.projectRoot);
}

/**
* Filters the exports of 'discovered' files to only be Classes (in case
* function / types are exported) as an artifact is a Class. The filtered
* artifact Classes are saved in the 'classes' property.
*
* NOTE: Booters extending this class should call this method (await super.load())
* and then process the artifact classes as appropriate.
*/
async load() {
this.classes = loadClassesFromFiles(this.discovered, this.projectRoot);
}
}
45 changes: 45 additions & 0 deletions packages/core/src/booters/core-controller.booter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {ArtifactOptions} from '@loopback/boot';
import {
BindingScope,
ControllerClass,
CoreBindings,
inject,
injectable,
} from '@loopback/core';
import {RestApplication} from '@loopback/rest';
import {BaseBooter} from './base.booter';

@injectable({scope: BindingScope.SINGLETON})
export class CoreControllerBooter extends BaseBooter {
constructor(
@inject(CoreBindings.APPLICATION_INSTANCE)
protected application: RestApplication,
) {
super();
}

async load(): Promise<void> {
await super.load();
this.classes.forEach(cls => {
this.bindController(cls);
});
}

bindController(controllerClass: ControllerClass<unknown>) {
const bindingKey = `controllers.${controllerClass.name}`;
if (!this.application.isBound(bindingKey)) {
this.application.controller(controllerClass);
} else {
return; // If already bound, skip
}
}
}

/**
* Default ArtifactOptions for ControllerBooter.
*/
export const ControllerDefaults: ArtifactOptions = {
dirs: ['controllers'],
extensions: ['.controller.js'],
nested: true,
};
57 changes: 57 additions & 0 deletions packages/core/src/booters/core-model.booter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {loadClassesFromFiles} from '@loopback/boot';
import {
BindingScope,
Component,
Context,
inject,
injectable,
MetadataInspector,
} from '@loopback/core';
import {glob} from 'glob';
import path from 'path';
import {OVERRIDE_MODEL_SCHEMA_KEY} from '../build-schema';
import {BaseBooter} from './base.booter';

@injectable({scope: BindingScope.SINGLETON})
export class CoreModelBooter extends BaseBooter {
constructor(
@inject.context()
protected readonly context: Context,
) {
super();
}

async discover(): Promise<void> {
const pattern = path.join(this.projectRoot, '**', '*component.js');
const filePaths = glob.sync(pattern, {nodir: true});
this.classes = loadClassesFromFiles(filePaths, this.projectRoot);
}
async load(): Promise<void> {
this.classes.forEach(cls => {
const componentKey = `components.${cls.name}`;

if (!this.context.isBound(componentKey)) {
return;
}

const componentInstance = this.context.getSync<Component>(componentKey);
const models = componentInstance?.models;

if (models && Array.isArray(models)) {
for (const model of models) {
const modelKey = 'models.' + model.name;
if (!this.context.isBound(modelKey)) {
continue;
}

const newModel = this.context.getSync<Function>(modelKey);
MetadataInspector.defineMetadata(
OVERRIDE_MODEL_SCHEMA_KEY,
newModel,
model,
);
}
}
});
}
}
2 changes: 2 additions & 0 deletions packages/core/src/booters/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './core-controller.booter';
export * from './core-model.booter';
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
export * from './adapters';
export * from './booters';
export * from './build-schema';
export * from './casbin-secure-sequence';
export * from './command';
Expand Down
42 changes: 42 additions & 0 deletions packages/core/src/mixins/booter.mixin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {ArtifactOptions, Booter} from '@loopback/boot';
import {Constructor, injectable} from '@loopback/core';
import {randomBytes} from 'crypto';

// Extend ArtifactOptions with a `key` identifier
export interface BooterOptionsWithKey extends ArtifactOptions {
interface?: string;
}
function generateRandomString(length: number): string {
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const bytes = randomBytes(length);
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(bytes[i] % chars.length);
}
return result;
}
const KEY_LENGTH = 5;
/**
* Mixin to override `projectRoot` and `options` based on a given base path.
*
* @param BooterClass The booter class to extend
* @param basePath The base path to be used as `projectRoot`
* @param defaultOptions Default options to merge with user config
*/
export function BooterBasePathMixin<T extends Constructor<Booter>>(
booterClass: T,
basePath: string,
defaultOptions: BooterOptionsWithKey,
): T {
@injectable({
tags: {
key: `${booterClass.name}_${defaultOptions.interface}_${generateRandomString(KEY_LENGTH)}`,
},
})
class NewClass extends booterClass {
projectRoot = basePath;
options = {...defaultOptions};
}
return NewClass as T;
}
3 changes: 2 additions & 1 deletion packages/core/src/mixins/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './base-entity.mixin';
export * from './user-modifiable-entity.mixin';
export * from './booter.mixin';
export * from './types';
export * from './user-modifiable-entity.mixin';
Loading