Skip to content

Commit

Permalink
README.md finished, API finished for 1.0
Browse files Browse the repository at this point in the history
  • Loading branch information
divmgl committed Oct 26, 2023
1 parent 40ca946 commit 1fd093e
Show file tree
Hide file tree
Showing 20 changed files with 673 additions and 165 deletions.
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
README.md
197 changes: 195 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import { Container } from "nwire"

const context = Container
//
.register("prisma", new PrismaClient())
.register("redis", new Redis())
.group("services", (container: Container) =>
Expand All @@ -36,7 +35,201 @@ class UsersService {

## API

(Coming soon)
`nwire` has two high-level concepts: the `Container` and the `Context`. The `Container` allows you to compose a strongly-typed `Context`, and the `Context` is the proxy that resolves dependencies for you lazily. The `Context` lives within the `Container` (as a closure) and interacts with the registration of your dependencies behind the scenes.

When using the library you likely won't have to think about these semantics, but we figured it's important to understand how it works under the hood.

### `Container`

The `Container` class is the main entrypoint for `nwire`. It provides a fluent API for registering
dependencies and creating `Context`s from them.

#### Creating a `Container`

You can use `new Container()` to create a container:

```tsx
const container = new Container()

container.register("prisma", new PrismaClient())
container.register("redis", new Redis())

const context = container.context()
```

In a majority of cases you'll be creating a single container, registering a bunch of dependencies, and then grabbing the generated `Context`. For this reason we've included static methods that return a new container and are chainable, so you can write your code like this instead:

```tsx
const context = Container
.register("prisma", () => new PrismaClient())
.register("redis", () => new Redis())
.context()
```

The choice is yours: you can keep the `Container` around in case you want to register more dependencies later, or you can simply grab the `Context`.

#### `Container.register`

Registers a dependency with the container.

```tsx
Container.register("prisma", () => new PrismaClient()) // => Container
```

The second argument is a function that returns the dependency.

Optionally you have access to the fully resolved `Context` at reolution time, in case you wish to do something with it:

```tsx
Container.register("users", (context) => new UsersService(context)) // => Container
```

> ⚠️ Due to `nwire`'s design, the `Context` that's sent to the dependency when it's resolved will always be the fully registered one.
This may not match with what the compiler sees (as TypeScript is only able to see what's been registered up until the point you called `register`). For instance, the following results in a compiler error:

```tsx
const context = Container
.register("tasksCreator", (context) => new TasksCreator(context))
// Argument of type '{}' is not assignable to parameter of type 'AppContext'.
// Type '{}' is missing the following properties from type 'AppContext': tasks, tasksCreator
.register("tasks", (context) => new SQLiteTaskStore(context))
```

One way to get around this is to explicitly let the `Container` know what kind of context you'll be building using the `build` method:

```tsx
const context = Container.build<AppContext>
.register("tasksCreator", (context) => new TasksCreator(context))
.register("tasks", (context) => new SQLiteTaskStore(context))
```

However, we've included a method to avoid this boilerplate altogether:

#### `Container.instance`

Your goal will often be to simply pass in the fully resolved `Context` to classes. For this reason, `nwire` provides a function that will create a new instance of your class with a fully resolved `Context` whenever the dependency is resolved:

```tsx
Container.instance("users", UsersService) // => Container
```

When the `users` dependency is used, `nwire` will create a new `UsersService` class with the resolved `Context` as the first parameter:

```tsx
const user = await context.users.findOne("123")

// Equivalent without nwire, sans singleton setup:
const users = new UsersService(container.context())
const user = await users.findOne("123")
```

You can also pass in additional arguments to the constructor:

```tsx
Container.instance("users", UsersService, { cookieSecret: process.env.COOKIE_SECRET })
```

#### `Container.context`

Creates a new `Context` class. This is the class you're meant to pass around to all of your dependencies. It's reponsible for resolving dependencies:

```tsx
const context = Container
// ... lots of registrations here
.register("users", () => new UsersService())
.context()

const user = await context.users.findOne("123")
// `users` is resolved lazily.
```

`nwire` will only resolve dependencies when they're needed. This is an intentional design decision to avoid
having to instantiate the entire `Container`, which is especially useful for tests.

#### `Container.group`

Sometimes you'll want to group things together within the `Container`. You could technically do this:

```tsx
const context = Container
//
.register("services", (context) => ({
users: new UsersService(context),
tasks: new TasksService(context),
}))
.context()
```

And now all services will be nested under `services`:

```tsx
context.services.users.findOne("123")
```

However, this has a big issue: once you access `service` for the first time you make an instance of every single class all at once.

`nwire` provides a solution for this: `Container.group`. `Container.group` creates a nested `Container` that will only resolve when you access properties within it. The nested container will be passed as the first argument to the function you pass in:

```tsx
const context = Container
//
.group("services", (services: Container) =>
services
//
.singleton("users", UsersService)
.singleton("tasks", TasksService)
)
.context()

type AppContext = typeof context
```
```tsx
type AppContext = {
services: {
users: UsersService
tasks: TasksService
}
}
```
```tsx
// Two contexts are used for resolution here: `context` and `services`
context.services.users.findOne("123")
```
### Lifetime of a dependency
`nwire` will resolve dependencies for you lazily and keep an instance of the dependency as a singleton by default.
```tsx
container.register("randomizer", () => new RandomizerClass())
container.resolve<RandomizerClass>("randomizer").id // => 353
container.resolve<RandomizerClass>("randomizer").id // => 353
```
Unless unregistered, the dependency will be kept in memory for the lifetime of the `Container`.
However, you can create transient dependencies by specifying the `{ transient: true }` option:
```tsx
container.register("randomizer", () => new RandomizerClass(), {
transient: true,
})
container.resolve<RandomizerClass>("randomizer").id // => 964
container.resolve<RandomizerClass>("randomizer").id // => 248
```
`nwire` will invoke this function when the `prisma` dependency is either resolved through the `Container` using `Container.resolve` or through the `Context` using `context.prisma`.
There is currently no API for transient `instance` registrations, so if you do want to create a unique instance on every call you'll need to provide an initial context:
```tsx
const context = Container.build<AppContext>
.register("users", (context) => new UsersService(context), { transient: true }))
.context()
```

## What is dependency injection?

Expand Down
25 changes: 25 additions & 0 deletions packages/example-fastify/src/AppContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Container } from "nwire"
import { TasksCreator } from "./TasksCreator"
import { createDatabase } from "./createDatabase"
import { SQLiteTaskStore } from "./SQLiteTaskStore"
import { Injected } from "nwire"

export type AppContext = {
db: Awaited<ReturnType<typeof createDatabase>>
tasksCreator: TasksCreator
tasks: SQLiteTaskStore
}

export async function createContext() {
const database = await createDatabase()

const context = Container.build<AppContext>()
.register("db", () => database)
.instance("tasksCreator", TasksCreator)
.instance("tasks", SQLiteTaskStore)
.context()

return context
}

export class Service extends Injected<AppContext> {}
56 changes: 11 additions & 45 deletions packages/example-fastify/src/SQLiteTaskStore.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,27 @@
import { Container } from "nwire"
import { TaskStore } from "./TaskStore"
import * as sqlite from "sqlite"
import { Task } from "./Task"
import * as sqlite3 from "sqlite3"

export class SQLiteTaskStore implements TaskStore {
db!: sqlite.Database

constructor() {
;(async () => {
this.db = await sqlite.open({
filename: ":memory:",
driver: sqlite3.Database,
})

this.db.run(
`CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
completed INTEGER NOT NULL DEFAULT 0
)`
)

this.db.run(
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
)`
)

this.db.run(
`CREATE TABLE IF NOT EXISTS users_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
task_id INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id),
FOREIGN KEY(task_id) REFERENCES tasks(id)
)`
)
})()
}
import { Service } from "./AppContext"

export class SQLiteTaskStore extends Service implements TaskStore {
async get(id: number): Promise<Task | null> {
return (await this.db.get(`SELECT * FROM tasks WHERE id = ?`, [id])) ?? null
return (
(await this.context.db.get(`SELECT * FROM tasks WHERE id = ?`, [id])) ??
null
)
}

async save(title: string): Promise<Task> {
const insert = await this.db.run(`INSERT INTO tasks (title) VALUES (?);`, [
title,
])
const insert = await this.context.db.run(
`INSERT INTO tasks (title) VALUES (?);`,
[title]
)

if (!insert.lastID) throw new Error("unable to save task")

return (await this.get(insert.lastID))!
}

async delete(id: number): Promise<void> {
await this.db.run(`DELETE FROM tasks WHERE id = ?`, [id])
await this.context.db.run(`DELETE FROM tasks WHERE id = ?`, [id])
}
}
6 changes: 2 additions & 4 deletions packages/example-fastify/src/TasksCreator.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { AppContext } from "./createServer"

export class TasksCreator {
constructor(private context: AppContext) {}
import { AppContext, Service } from "./AppContext"

export class TasksCreator extends Service {
async createBasicTasks() {
await this.context.tasks.save("My first test")
await this.context.tasks.save("My second test")
Expand Down
36 changes: 36 additions & 0 deletions packages/example-fastify/src/createDatabase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as sqlite from "sqlite"
import * as sqlite3 from "sqlite3"

export async function createDatabase() {
const db = await sqlite.open({
filename: ":memory:",
driver: sqlite3.Database,
})

await db.run(
`CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
completed INTEGER NOT NULL DEFAULT 0
)`
)

await db.run(
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
)`
)

await db.run(
`CREATE TABLE IF NOT EXISTS users_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
task_id INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id),
FOREIGN KEY(task_id) REFERENCES tasks(id)
)`
)

return db
}
12 changes: 9 additions & 3 deletions packages/example-fastify/src/createServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { SQLiteTaskStore } from "./SQLiteTaskStore"
import { Container } from "nwire"
import { SuperAgentTest, agent } from "supertest"
import { TasksCreator } from "./TasksCreator"
import { AppContext } from "./AppContext"
import { createDatabase } from "./createDatabase"

declare module "vitest" {
export interface TestContext {
Expand All @@ -13,13 +15,17 @@ declare module "vitest" {

describe("server", function () {
beforeEach(async (context) => {
const container = Container
const database = await createDatabase()

const container = Container.build<AppContext>()
//
.singleton("tasks", SQLiteTaskStore)
.singleton("tasksCreator", TasksCreator)
.register("db", () => database)
.instance("tasks", SQLiteTaskStore)
.instance("tasksCreator", TasksCreator)

const server = createServer(container.context())
await server.ready()

context.request = agent(server.server)
})

Expand Down
Loading

0 comments on commit 1fd093e

Please sign in to comment.