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
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,12 +235,19 @@ Supported strategies:

This is especially useful in tests.

### `container.set(metadata)`
### `container.set(id, valueOrProvider)`

Registers or replaces service metadata for a service identifier.
Registers or replaces a bound value or provider for a service identifier.

This is a low-level API that powers manual registration scenarios and internal tests.
For application-facing code, prefer `@Service()` unless you specifically need to construct metadata yourself.
Supported forms today:

- `container.set(id, value)`
- `container.set(id, { useValue: value })`
- `container.set(id, { useClass: Class, scope? })`
- `container.set(id, { useFactory: (container) => value, scope? })`

This is the low-level API for manual value, class, and factory registration.
For decorator-driven classes, prefer `@Service()`.

## Internal architecture

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "navi-di",
"version": "1.1.0",
"version": "1.1.1",
"description": "Dependency injection for standard ECMAScript decorators.",
"author": "naviary-sanctuary",
"keywords": [
Expand Down
88 changes: 77 additions & 11 deletions src/container/container.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { CircularDependencyError, ServiceNotFoundError } from '../errors';
import type { ContainerIdentifier, Metadata, ServiceIdentifier } from '../types';
import type {
ClassProvider,
ContainerIdentifier,
FactoryProvider,
Metadata,
ServiceIdentifier,
ServiceProvider,
ValueProvider,
} from '../types';
import { EMPTY_VALUE } from '../types';
import { ContainerRegistry } from './registry';

Expand Down Expand Up @@ -27,6 +35,45 @@ export class Container {
this.id = id;
}

private isValueProvider<T>(provider: T | ServiceProvider<T>): provider is ValueProvider<T> {
return typeof provider === 'object' && provider !== null && 'useValue' in provider;
}

private isClassProvider<T>(provider: T | ServiceProvider<T>): provider is ClassProvider<T> {
return typeof provider === 'object' && provider !== null && 'useClass' in provider;
}

private isFactoryProvider<T>(provider: T | ServiceProvider<T>): provider is FactoryProvider<T> {
return typeof provider === 'object' && provider !== null && 'useFactory' in provider;
}

private getClassProviderMetadata<T>(id: ServiceIdentifier<T>, provider: ClassProvider<T>): Metadata<T> {
const existingMetadata =
this.metadataMap.get(provider.useClass) ?? ContainerRegistry.defaultContainer.metadataMap.get(provider.useClass);
const inheritedInjections = existingMetadata?.Class === provider.useClass ? [...existingMetadata.injections] : [];
const inheritedScope = existingMetadata?.Class === provider.useClass ? existingMetadata.scope : 'container';

return {
id,
Class: provider.useClass,
name: provider.useClass.name || String(id),
injections: provider.injections ?? inheritedInjections,
scope: provider.scope ?? inheritedScope,
value: EMPTY_VALUE,
};
}

private getFactoryProviderMetadata<T>(id: ServiceIdentifier<T>, provider: FactoryProvider<T>): Metadata<T> {
return {
id,
name: typeof id === 'function' ? id.name : String(id),
injections: [],
scope: provider.scope ?? 'container',
value: EMPTY_VALUE,
factory: provider.useFactory,
};
}

/**
* Returns the default container or a named container.
*
Expand Down Expand Up @@ -98,17 +145,36 @@ export class Container {
}

/**
* Binds a concrete value to this container.
* Registers a value or provider for a service identifier.
*
* Bound values are returned as-is when the same identifier is requested from
* this container.
* Use a plain value to bind an explicit instance, or a provider object to
* resolve by class or factory.
*
* @param id The service identifier to bind.
* @param value The value to return for that identifier.
* @param id The service identifier to register.
* @param valueOrProvider The bound value or provider definition.
* @returns The current container.
*/
public set<T>(id: ServiceIdentifier<T>, value: T) {
this.bindingMap.set(id, value);
public set<T>(id: ServiceIdentifier<T>, value: T): this;
public set<T>(id: ServiceIdentifier<T>, provider: ServiceProvider<T>): this;
public set<T>(id: ServiceIdentifier<T>, valueOrProvider: T | ServiceProvider<T>) {
if (this.isValueProvider(valueOrProvider)) {
this.bindingMap.set(id, valueOrProvider.useValue);
this.metadataMap.delete(id);
return this;
}

if (this.isClassProvider(valueOrProvider)) {
this.bindingMap.delete(id);
return this.register(this.getClassProviderMetadata(id, valueOrProvider));
}

if (this.isFactoryProvider(valueOrProvider)) {
this.bindingMap.delete(id);
return this.register(this.getFactoryProviderMetadata(id, valueOrProvider));
}

this.bindingMap.set(id, valueOrProvider);
this.metadataMap.delete(id);
return this;
}

Expand Down Expand Up @@ -166,10 +232,10 @@ export class Container {
return this.bindingMap.get(id) as T;
}

let metadata = this.metadataMap.get(id);
let metadata = this.metadataMap.get(id) as Metadata<T> | undefined;

if (!metadata && !this.isDefault()) {
const defaultMetadata = ContainerRegistry.defaultContainer.metadataMap.get(id);
const defaultMetadata = ContainerRegistry.defaultContainer.metadataMap.get(id) as Metadata<T> | undefined;

if (!defaultMetadata) {
throw new ServiceNotFoundError(id);
Expand Down Expand Up @@ -208,7 +274,7 @@ export class Container {
this.resolvingPath.push(id);

try {
const instance = new metadata.Class() as T;
const instance: T = metadata.factory ? metadata.factory(this) : new metadata.Class!();

for (const injection of metadata.injections) {
Object.defineProperty(instance, injection.name, {
Expand Down
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,10 @@ export { Token } from './tokens';

export type { AbstractConstructable, Constructable } from './types/constructable.ts';
export type { ServiceIdentifier } from './types/service.ts';
export type {
ClassProvider,
FactoryProvider,
ServiceFactory,
ServiceProvider,
ValueProvider,
} from './types/container.ts';
63 changes: 62 additions & 1 deletion src/types/container.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Constructable } from './constructable';
import type { InjectionMetadata } from './injection';
import type { ServiceIdentifier, ServiceScope } from './service';
import type { Container } from '../container';

/**
* A container identifier used to select the default container or a named container.
Expand All @@ -12,6 +13,61 @@ export type ContainerIdentifier = string | symbol;
*/
export const EMPTY_VALUE = Symbol.for('EMPTY_VALUE');

/**
* A factory function that creates a service using the current container.
*/
export type ServiceFactory<T> = (container: Container) => T;

/**
* A provider that always resolves to the same explicit value.
*/
export interface ValueProvider<T> {
/**
* The value returned for the registered identifier.
*/
useValue: T;
}

/**
* A provider that resolves by instantiating a class.
*/
export interface ClassProvider<T> {
/**
* The class instantiated when the service is resolved.
*/
useClass: Constructable<T>;

/**
* Optional property injection definitions for the registered class.
*/
injections?: InjectionMetadata[];

/**
* The lifetime used when the service is resolved.
*/
scope?: ServiceScope;
}

/**
* A provider that resolves by calling a factory function.
*/
export interface FactoryProvider<T> {
/**
* The factory used to create the resolved value.
*/
useFactory: ServiceFactory<T>;

/**
* The lifetime used when the service is resolved.
*/
scope?: ServiceScope;
}

/**
* A provider object accepted by low-level container registration APIs.
*/
export type ServiceProvider<T> = ValueProvider<T> | ClassProvider<T> | FactoryProvider<T>;

/**
* Service registration metadata stored by a container.
*
Expand All @@ -26,7 +82,7 @@ export interface Metadata<T = unknown> {
/**
* The class instantiated for this service.
*/
Class: Constructable<T>;
Class?: Constructable<T>;

/**
* The original class or member name, when available.
Expand All @@ -47,4 +103,9 @@ export interface Metadata<T = unknown> {
* The cached service instance, or `EMPTY_VALUE` when no instance is cached.
*/
value: T | typeof EMPTY_VALUE;

/**
* An optional factory used to create the resolved value.
*/
factory?: ServiceFactory<T>;
}
11 changes: 10 additions & 1 deletion src/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
export type { AbstractConstructable, Constructable } from './constructable';
export type { ServiceIdentifier, ServiceOption } from './service';
export { type ContainerIdentifier, type Metadata, EMPTY_VALUE } from './container';
export {
type ClassProvider,
type ContainerIdentifier,
EMPTY_VALUE,
type FactoryProvider,
type Metadata,
type ServiceFactory,
type ServiceProvider,
type ValueProvider,
} from './container';
export { type InjectionMetadata, INJECTION_KEY } from './injection';
84 changes: 84 additions & 0 deletions test/container/container.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ afterEach(() => {
ContainerRegistry.removeContainer('container-reset-fallback');
ContainerRegistry.removeContainer('container-local-has');
ContainerRegistry.removeContainer('container-post-error');
ContainerRegistry.removeContainer('factory-container-scope');
ContainerRegistry.removeContainer('factory-singleton-scope');
});

describe('Container', () => {
Expand Down Expand Up @@ -108,6 +110,88 @@ describe('Container', () => {

expect(Container.of().get(BoundService)).toBe(bound);
});

test('registers a class provider for a custom identifier', () => {
interface Logger {
log(message: string): string;
}

class ConsoleLogger implements Logger {
public log(message: string) {
return message;
}
}

Container.of().set<Logger>('logger', { useClass: ConsoleLogger });

const logger = Container.of().get<Logger>('logger');

expect(logger).toBeInstanceOf(ConsoleLogger);
expect(logger.log('hello')).toBe('hello');
});

test('registers a factory provider that can resolve from the current container', () => {
class DependencyService {
public readonly name = 'dependency';
}

class CompositeService {
constructor(public readonly dependency: DependencyService) {}
}

Container.of().register({
id: DependencyService,
Class: DependencyService,
name: 'DependencyService',
injections: [],
scope: 'container',
value: EMPTY_VALUE,
});

Container.of().set(CompositeService, {
useFactory: (container) => new CompositeService(container.get(DependencyService)),
});

const composite = Container.of().get(CompositeService);

expect(composite).toBeInstanceOf(CompositeService);
expect(composite.dependency).toBe(Container.of().get(DependencyService));
});

test('supports container-scoped factory providers per named container', () => {
const firstContainer = Container.of('factory-container-scope');
let created = 0;

Container.of().set('request-id', {
useFactory: () => ({ id: ++created }),
scope: 'container',
});

const defaultFirst = Container.of().get<{ id: number }>('request-id');
const defaultSecond = Container.of().get<{ id: number }>('request-id');
const namedFirst = firstContainer.get<{ id: number }>('request-id');
const namedSecond = firstContainer.get<{ id: number }>('request-id');

expect(defaultFirst).toBe(defaultSecond);
expect(namedFirst).toBe(namedSecond);
expect(defaultFirst).not.toBe(namedFirst);
});

test('supports singleton factory providers across containers', () => {
const requestContainer = Container.of('factory-singleton-scope');
let created = 0;

Container.of().set('singleton-id', {
useFactory: () => ({ id: ++created }),
scope: 'singleton',
});

const defaultInstance = Container.of().get<{ id: number }>('singleton-id');
const requestInstance = requestContainer.get<{ id: number }>('singleton-id');

expect(defaultInstance).toBe(requestInstance);
expect(created).toBe(1);
});
});

describe('remove', () => {
Expand Down
Loading