Skip to content

Commit

Permalink
feat(ui): add dependency injection container (#1006)
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
qwqcode authored Oct 17, 2024
1 parent 5065bb4 commit 437892c
Show file tree
Hide file tree
Showing 3 changed files with 168 additions and 0 deletions.
102 changes: 102 additions & 0 deletions ui/artalk/src/lib/injection/container.test.ts
Original file line number Diff line number Diff line change
@@ -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<Deps>()

// 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)
})
})
65 changes: 65 additions & 0 deletions ui/artalk/src/lib/injection/container.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
type PrimitiveKey = string | number | symbol
type Services = { [key: PrimitiveKey]: any }

export type Constructor<T, S = Services, D extends readonly (keyof S)[] = any> = (
...args: { [K in keyof D]: D[K] extends keyof S ? S[D[K]] : never }
) => T

export type Lifecycle = 'transient' | 'singleton'

export interface Provider<T, S = Services> {
/** Implementation constructor */
impl: Constructor<T>

/** Dependency constructors */
deps: readonly (keyof S)[]

/** Lifecycle */
lifecycle: Lifecycle
}

export interface ProvideFuncOptions {
/** Lifecycle (default: 'singleton') */
lifecycle?: Lifecycle
}

export interface DependencyContainer<S = Services> {
provide<K extends keyof S, T extends S[K] = any, D extends readonly (keyof S)[] = any>(
key: K,
impl: Constructor<T, S, D>,
deps?: D,
opts?: ProvideFuncOptions,
): void

inject<T = undefined, K extends keyof S = any>(key: K): T extends undefined ? S[K] : T
}

export function createInjectionContainer<S = Services>(): DependencyContainer<S> {
const providers = new Map<PrimitiveKey, Provider<any, S>>()
const initializedDeps = new Map<PrimitiveKey, any>()

const provide: DependencyContainer<S>['provide'] = (key, impl, deps, opts = {}) => {
providers.set(key, { impl, deps: deps || [], lifecycle: opts.lifecycle || 'singleton' })
}

const inject: DependencyContainer<S>['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 }
}
1 change: 1 addition & 0 deletions ui/artalk/src/lib/injection/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './container'

0 comments on commit 437892c

Please sign in to comment.