From 28b4750dfe5e6f57425cbdfdc5da8cec9962b17f Mon Sep 17 00:00:00 2001 From: qwqcode Date: Thu, 17 Oct 2024 22:46:05 +0800 Subject: [PATCH] feat(ui): add dependency injection container - Implement `createInjectionContainer` for managing service lifecycles - Implement `provide` and `inject` for dependency injection - Support singleton and transient service lifecycles - Add unit tests for container functionality --- ui/artalk/src/lib/injection/container.test.ts | 102 ++++++++++++++++++ ui/artalk/src/lib/injection/container.ts | 65 +++++++++++ ui/artalk/src/lib/injection/index.ts | 1 + 3 files changed, 168 insertions(+) create mode 100644 ui/artalk/src/lib/injection/container.test.ts create mode 100644 ui/artalk/src/lib/injection/container.ts create mode 100644 ui/artalk/src/lib/injection/index.ts diff --git a/ui/artalk/src/lib/injection/container.test.ts b/ui/artalk/src/lib/injection/container.test.ts new file mode 100644 index 00000000..0203d53b --- /dev/null +++ b/ui/artalk/src/lib/injection/container.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { createInjectionContainer } from './container' + +describe('InjectionContainer', () => { + it('should inject a singleton service', () => { + const container = createInjectionContainer() + + // Mock Service + const myService = { + getValue: () => 'test value', + } + + // Provide service as singleton + container.provide('myService', () => myService) + + // Inject service and validate + const injectedService = container.inject('myService') + expect(injectedService).toBe(myService) + }) + + it('should inject a transient service', () => { + const container = createInjectionContainer() + + // Provide service as transient + container.provide('myService', () => ({ id: Math.random() }), [], { lifecycle: 'transient' }) + + // Inject service twice and expect different instances + const service1 = container.inject('myService') + const service2 = container.inject('myService') + expect(service1).not.toBe(service2) + }) + + it('should throw error when service is not provided', () => { + const container = createInjectionContainer() + + // Try injecting a non-existent service + expect(() => container.inject('nonExistentService')).toThrowError( + 'No provide for nonExistentService', + ) + }) + + it('should inject services with dependencies', () => { + type Dep1 = { getName: () => string } + type Dep2 = { getName: () => string } + type Deps = { dep1: Dep1; dep2: Dep2; myService: MyService } + + const container = createInjectionContainer() + + // Mock dependencies + const dep1: Dep1 = { getName: () => 'Dependency 1' } + const dep2: Dep2 = { getName: () => 'Dependency 2' } + + // Provide dependencies + container.provide('dep1', () => dep1) + container.provide('dep2', () => dep2) + + // Mock service depending on dep1 and dep2 + class MyService { + constructor( + public dep1: Dep1, + public dep2: Dep2, + ) {} + + getDeps() { + return [this.dep1.getName(), this.dep2.getName()] + } + } + + container.provide('myService', (dep1, dep2) => new MyService(dep1, dep2), [ + 'dep1', + 'dep2', + ] as const) + + // Inject service and validate dependencies + const myService = container.inject('myService') + expect(myService.getDeps()).toEqual(['Dependency 1', 'Dependency 2']) + }) + + it('should inject singleton with dependencies only once', () => { + const container = createInjectionContainer() + + // Mock dependencies + const dep1 = { id: Math.random() } + const dep2 = { id: Math.random() } + + // Provide dependencies as singletons + container.provide('dep1', () => dep1) + container.provide('dep2', () => dep2) + + // Provide service depending on dep1 and dep2 + container.provide('myService', (dep1, dep2) => ({ dep1, dep2 }), ['dep1', 'dep2']) + + // Inject service and dependencies + const myService1 = container.inject('myService') + const myService2 = container.inject('myService') + + // Expect same instance for singleton + expect(myService1).toBe(myService2) + expect(myService1.dep1).toBe(dep1) + expect(myService1.dep2).toBe(dep2) + }) +}) diff --git a/ui/artalk/src/lib/injection/container.ts b/ui/artalk/src/lib/injection/container.ts new file mode 100644 index 00000000..3c9d4248 --- /dev/null +++ b/ui/artalk/src/lib/injection/container.ts @@ -0,0 +1,65 @@ +type PrimitiveKey = string | number | symbol +type Services = { [key: PrimitiveKey]: any } + +export type Constructor = ( + ...args: { [K in keyof D]: D[K] extends keyof S ? S[D[K]] : never } +) => T + +export type Lifecycle = 'transient' | 'singleton' + +export interface Provider { + /** Implementation constructor */ + impl: Constructor + + /** Dependency constructors */ + deps: readonly (keyof S)[] + + /** Lifecycle */ + lifecycle: Lifecycle +} + +export interface ProvideFuncOptions { + /** Lifecycle (default: 'singleton') */ + lifecycle?: Lifecycle +} + +export interface DependencyContainer { + provide( + key: K, + impl: Constructor, + deps?: D, + opts?: ProvideFuncOptions, + ): void + + inject(key: K): T extends undefined ? S[K] : T +} + +export function createInjectionContainer(): DependencyContainer { + const providers = new Map>() + const initializedDeps = new Map() + + const provide: DependencyContainer['provide'] = (key, impl, deps, opts = {}) => { + providers.set(key, { impl, deps: deps || [], lifecycle: opts.lifecycle || 'singleton' }) + } + + const inject: DependencyContainer['inject'] = (key) => { + const provider = providers.get(key) + if (!provider) { + throw new Error(`No provide for ${String(key)}`) + } + + if (provider.lifecycle === 'singleton' && initializedDeps.has(key)) { + return initializedDeps.get(key) + } + + const { impl, deps } = provider + const params = deps.map((d) => inject(d)) + const resolved = impl(...params) + + initializedDeps.set(key, resolved) + + return resolved + } + + return { provide, inject } +} diff --git a/ui/artalk/src/lib/injection/index.ts b/ui/artalk/src/lib/injection/index.ts new file mode 100644 index 00000000..a4b85ca9 --- /dev/null +++ b/ui/artalk/src/lib/injection/index.ts @@ -0,0 +1 @@ +export * from './container'