Skip to content

Commit

Permalink
Add Simpldi Package (#1)
Browse files Browse the repository at this point in the history
* update ci config

* child container and init testing

* simple provider resolution

* add support for transient providers

* add error case

* resolve from parents

* add init lifecycle interface

* add changelogs

* inital readme and rename packages

* readme and changelogs

* allow warnings for tests
  • Loading branch information
jes3rk authored Nov 11, 2024
1 parent 2dbb137 commit 3c05f97
Show file tree
Hide file tree
Showing 14 changed files with 422 additions and 12 deletions.
17 changes: 8 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v3.1.1
with:
node-version: 16.x
node-version: 20.x
- name: Restore cache
uses: actions/cache@v3.2.4
with:
Expand All @@ -28,13 +28,12 @@ jobs:
run: node common/scripts/install-run-rush.js check
- name: Assert Changelogs
run: node common/scripts/install-run-rush.js change --verify
test-nest-cqrs:
test-packages:
needs: verify
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x]
package: [core, eventstoredb, 'nats']
node-version: [20.x]
steps:
- name: Checkout
uses: actions/checkout@v3
Expand All @@ -48,13 +47,13 @@ jobs:
path: common/temp/pnpm-store
key: ${{ runner.os }}-{{ hashFiles('common/config/rush/pnpm-lock.yaml') }}
- name: Rush Install
run: node common/scripts/install-run-rush.js install
run: node common/scripts/install-run-rush.js install --to tag:package
- name: Build
run: |
node common/scripts/install-run-rush.js build -T @nest-cqrs/${{ matrix.package }}
node common/scripts/install-run-rush.js build --to tag:package
- name: Test
run: |
npm run test --prefix packages/${{ matrix.package }}
node common/scripts/install-run-rush.js test --to tag:package
publsh:
runs-on: ubuntu-latest
needs: [verify]
Expand All @@ -72,8 +71,8 @@ jobs:
path: common/temp/pnpm-store
key: ${{ runner.os }}-{{ hashFiles('common/config/rush/pnpm-lock.yaml') }}
- name: Rush Install
run: node common/scripts/install-run-rush.js install
run: node common/scripts/install-run-rush.js install --to tag:package
- name: Rush Build
run: node common/scripts/install-run-rush.js build
run: node common/scripts/install-run-rush.js build --to tag:package
- name: Publish
run: node common/scripts/install-run-rush.js publish --publish -n ${{ secrets.NPM_TOKEN }} --include-all --set-access-level public
10 changes: 10 additions & 0 deletions common/changes/@dtty/simpldi/add-simpldi_2024-11-11-16-06.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@dtty/simpldi",
"comment": "Initial release",
"type": "minor"
}
],
"packageName": "@dtty/simpldi"
}
1 change: 1 addition & 0 deletions common/config/rush/command-line.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"summary": "Run test scripts in each of the packages in the project",
"safeForSimultaneousRushProcesses": false,
"shellCommand": "rushx test",
"allowWarningsInSuccessfulBuild": true,
"enableParallelism": true
}
// {
Expand Down
8 changes: 8 additions & 0 deletions common/config/rush/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

78 changes: 78 additions & 0 deletions packages/simpldi/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# @dtty/simpldi

A simple dependency injection library for typescript projects.

## Installation

This library requires the `reflect-metadata` package to function properly.
Add the line `import 'reflect-metadata';` to the top of you entry file (e.g. index.js).

```sh
npm i @dtty/simpldi reflect-metadata
```

## Usage

Unlike many other dependency injection libraries for Typescript projects, `@dtty/simpldi` requires developers to manage the container lifecycle themselvs.
While some may consider this a burden, it ultimately allows for far more flexible configurations and a simpler API to engage with.

```ts
// Basic usage

const rootContainer = new Container();

// Add a simple provider
rootContainer.addProvider(simpleToken, SimpleProviderClass);

// Add a transient provider
rootContainer.addProvider(transientToken, TransientProviderClass, {
mode: ProviderMode.TRANSIENT,
});

// Fetch a provider
const instance = await rootContainer.resolveProvider(simpleToken);
```

### Tokens

Tokens in `@dtty/simpldi` are used in place of magic strings for dependency resolution.
This enables stricter type checking when manually resolving dependencies.

```ts
// Create a token
const token = new Token<SimpleProviderClass>();

// Optionall, add a name to the token
const tokenWithName = new Token<SimpleProviderClass>("My Name");
```

### Modes

By default, all providers in the container are registered as singletons.
This behavior can be optionally changed by setting `mode: ProviderMode.TRANSIENT` when registering a provider.
Transient providers will be re-initialized each time they are resolved, ensuring all downstream providers recieve a fresh instance.

### Nested Containers

Providers are always scoped to their container but can depend on providers from a parent container.
Creating a child container allows developers to scope specific providers to the lifetime of the container. for example the use of a child container for request-specific providers in a web server.

```ts
// Start with a root
const rootContainer = new Container();

// And spawn a child
const childContainer = rootContainer.createChildContainer();
```

### Lifecycle

By default, all providers a lazy loaded into the container, meaning that provider instances are only created when the are needed.
Lazy loading enables a faster application start time and ensures that only the relevant classes are created, cutting down on overhead.

Below are ways to hook into the provider lifecycle.

#### onProviderInit

By implementing the `IOnProviderInit` interface, a provider adds a lifecycle method that is called whenever the provider instance is resolved in the container.
This can be useful for bootstrapping common providers like database connections that require an asynchronous interaction after creation.
5 changes: 4 additions & 1 deletion packages/simpldi/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "@ditty/simpldi",
"name": "@dtty/simpldi",
"version": "0.0.0",
"description": "Simple and compact Dependency Injection library for TS",
"license": "ISC",
Expand All @@ -10,6 +10,9 @@
"build": "npm run prebuild && tsc -p tsconfig.build.json",
"test": "env JEST_ROOT_DIR=packages/simpldi jest --config ../../jest.config.js"
},
"dependencies": {
"reflect-metadata": "~0.2.2"
},
"devDependencies": {
"@swc/core": "~1.9.1",
"@swc/jest": "~0.2.37",
Expand Down
145 changes: 145 additions & 0 deletions packages/simpldi/src/container.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { Container } from "./container";
import { Inject } from "./inject";
import { ProviderNotFoundException } from "./provider-not-found.exception";
import { Token } from "./token";
import { IOnProviderInit, ProviderMode } from "./types";

describe("Container interactions", () => {
let rootContainer: Container;
class ProviderWithNoDeps {
public readonly Id: string;
constructor() {
this.Id = (Math.random() * Date.now()).toString();
}
}

const noDepsToken = new Token<ProviderWithNoDeps>("No deps");

class ProviderWithOneDep extends ProviderWithNoDeps {
constructor(@Inject(noDepsToken) public readonly dep: ProviderWithNoDeps) {
super();
}
}

const oneDepToken = new Token<ProviderWithOneDep>("One Dep");

class ProviderWithMultipleDeps extends ProviderWithNoDeps {
constructor(
@Inject(noDepsToken) public readonly noDeps: ProviderWithNoDeps,
@Inject(oneDepToken) public readonly oneDep: ProviderWithOneDep,
) {
super();
}
}

const mulipleDepToken = new Token<ProviderWithMultipleDeps>("many deps");

beforeEach(() => {
rootContainer = new Container();
});

it("should create a child container linked to the root", () => {
const child = rootContainer.createChildContainer();
expect(child["parent"]).toEqual(rootContainer);
});

describe("Single layer provider lifecycle", () => {
it("should resolve a single provider with no dependencies", async () => {
rootContainer.addProvider(noDepsToken, ProviderWithNoDeps);
const provider = await rootContainer.resolveProvider(noDepsToken);
expect(provider).toBeInstanceOf(ProviderWithNoDeps);
expect(provider.Id).not.toBeUndefined();
});

it("should always resolve the same provider instance by default", async () => {
rootContainer.addProvider(noDepsToken, ProviderWithNoDeps);
const provider1 = await rootContainer.resolveProvider(noDepsToken);
const provider2 = await rootContainer.resolveProvider(noDepsToken);
expect(provider1.Id).toEqual(provider2.Id);
});

it("should resolve a single provider with a singleton dependency", async () => {
rootContainer.addProvider(noDepsToken, ProviderWithNoDeps);
rootContainer.addProvider(oneDepToken, ProviderWithOneDep);

const provider = await rootContainer.resolveProvider(oneDepToken);
expect(provider).toBeInstanceOf(ProviderWithOneDep);
expect(provider.dep).toBeInstanceOf(ProviderWithNoDeps);
expect(provider.Id).not.toEqual(provider.dep.Id);
});

it("should resolve a single provider with a transient dependency", async () => {
rootContainer.addProvider(oneDepToken, ProviderWithOneDep);
rootContainer.addProvider(noDepsToken, ProviderWithNoDeps, {
mode: ProviderMode.TRANSIENT,
});
const provider1 = await rootContainer.resolveProvider(oneDepToken);
const provider2 = await rootContainer.resolveProvider(noDepsToken);
expect(provider1.dep.Id).not.toEqual(provider2.Id);
});

it("should resolve a provider with multiple dependencies", async () => {
rootContainer.addProvider(noDepsToken, ProviderWithNoDeps);
rootContainer.addProvider(oneDepToken, ProviderWithOneDep);
rootContainer.addProvider(mulipleDepToken, ProviderWithMultipleDeps);
const provider = await rootContainer.resolveProvider(mulipleDepToken);
expect(provider).toBeInstanceOf(ProviderWithMultipleDeps);
expect(provider.noDeps.Id).toEqual(provider.oneDep.dep.Id);
});

it("should throw an exception when resolving a missing provider", async () => {
await expect(() =>
rootContainer.resolveProvider(noDepsToken),
).rejects.toThrow(ProviderNotFoundException);
});
});

describe("Multi layer provider lifecycle", () => {
let childContainer: Container;

beforeEach(() => {
childContainer = rootContainer.createChildContainer();
});

it("should resolve a provider from the child using a parent dependency", async () => {
rootContainer.addProvider(noDepsToken, ProviderWithNoDeps);
childContainer.addProvider(oneDepToken, ProviderWithOneDep);
const provider1 = await childContainer.resolveProvider(oneDepToken);
const provider2 = await rootContainer.resolveProvider(noDepsToken);
expect(provider1.dep.Id).toEqual(provider2.Id);
});

it("should throw an exception when resolving a provider that depends on the child", async () => {
childContainer.addProvider(noDepsToken, ProviderWithNoDeps);
rootContainer.addProvider(oneDepToken, ProviderWithOneDep);
await expect(() =>
childContainer.resolveProvider(oneDepToken),
).rejects.toThrow(ProviderNotFoundException);
});
});

describe("Provider lifecycle methods", () => {
class InitAbleProvider
extends ProviderWithNoDeps
implements IOnProviderInit
{
public isInit: boolean;

constructor() {
super();
this.isInit = false;
}
public onProviderInit(): void {
this.isInit = true;
}
}

const initToken = new Token<InitAbleProvider>();

it("should call onProviderInit if present when constructing a provider instance", async () => {
rootContainer.addProvider(initToken, InitAbleProvider);
const provider = await rootContainer.resolveProvider(initToken);
expect(provider.isInit).toEqual(true);
});
});
});
Loading

0 comments on commit 3c05f97

Please sign in to comment.