diff --git a/README.md b/README.md index 1dd3e58..0ef4abd 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,107 @@ I am answering this question from a framework creator perspective. I never use t So, if you create packages for AdonisJS, I highly recommend using factory functions. Leave the `@inject` decorator for the end user. +## Conditional bindings + +Use `container.if` to select a binding at resolution time. The condition may be synchronous or asynchronous, and it is evaluated every time the binding is resolved. + +For example: You are rolling out a new payment gateway to a small set of users. Every class asking for `PaymentGateway` keeps doing so, and the container decides which implementation they get. + +```ts +container.bind(PaymentGateway, (resolver) => { + return resolver.make(StripePaymentGateway) +}) + +container + .if(async (resolver) => { + const ctx = await resolver.make(HttpContext) + return ctx.auth.user?.featureFlags.includes('new-payment') === true + }) + .bind(PaymentGateway, (resolver) => resolver.make(BetaPaymentGateway)) +``` + +You may register multiple conditional bindings for the same key. Conditions are evaluated in registration order and the first match is used. When none match, the container falls back to a regular binding or its default resolution behavior. + +### Conditional singletons + +The builder also exposes a `singleton` method. Just like `container.singleton`, the factory function is called only once and its return value is cached forever. + +The condition is still evaluated on every resolution. Only the factory function result is cached. + +```ts +container + .if(() => env.get('SEARCH_DRIVER') === 'typesense') + .singleton(SearchService, (resolver) => resolver.make(TypesenseSearchService)) +``` + +### Condition arguments + +A condition receives the resolver performing the resolution, the runtime values, and the parent class asking for the binding. + +```ts +container.if((resolver, runtimeValues, parent) => true) +``` + +The `resolver` gives the condition access to values local to that resolver. In the example at the top of this section, `HttpContext` is available because the AdonisJS starter kits bind it per request using `ContainerBindingsMiddleware`. + +The `parent` is the class the binding is getting injected into, or `null` when the binding is resolved directly using `make`. Use it to combine a predicate with the calling class. + +```ts +container + .if((_, __, parent) => parent === CheckoutController && isBetaUser()) + .bind(PaymentGateway, (resolver) => resolver.make(BetaPaymentGateway)) +``` + +The `runtimeValues` are only available when the binding is resolved directly using `make` or `call`. Dependencies resolved for a class receive `undefined`. + +### Conditions run everywhere + +A condition is evaluated for every resolution of its binding key, not just during HTTP requests. The same binding is also resolved inside queue workers, Ace commands, and at boot time, where request specific values do not exist. + +Guard against their absence, otherwise the condition throws when it reaches for a binding that was never registered. + +```ts +container + .if(async (resolver) => { + if (!resolver.hasBinding(HttpContext)) { + return false + } + + const ctx = await resolver.make(HttpContext) + return ctx.auth.user?.featureFlags.includes('new-payment') === true + }) + .bind(PaymentGateway, (resolver) => resolver.make(BetaPaymentGateway)) +``` + +### Resolution order + +The container tries the following sources in order and returns the first one that produces a value. + +| Order | Source | Registered using | Applies to | +| ----- | -------------------- | ---------------------------------------- | --------------------- | +| 1 | Swaps | `container.swap` | Classes only | +| 2 | Contextual bindings | `container.when().asksFor().provide()` | Classes with a parent | +| 3 | Resolver values | `resolver.bindValue` | All binding keys | +| 4 | Container values | `container.bindValue` | All binding keys | +| 5 | Conditional bindings | `container.if().bind()` | All binding keys | +| 6 | Bindings | `container.bind` / `container.singleton` | All binding keys | +| 7 | Class construction | — | Classes only | + +Two consequences worth knowing. Swaps win over conditional bindings, so faking a class in tests works regardless of any condition. And values win over conditional bindings, so `bindValue` overrides a conditional binding the same way it overrides a regular one. + +### Conditional bindings and `hasBinding` + +`container.hasBinding` returns `true` for a key that only has conditional bindings registered, because the binding genuinely is registered. It may still fail to resolve when none of the conditions match. + +```ts +container.if(() => false).bind('gateway', () => new BetaPaymentGateway()) + +container.hasBinding('gateway') // true +await container.make('gateway') // throws +``` + +The error points at the unmatched conditions, so register a fallback using `container.bind` when the key must always resolve. + ## Binding singletons You can bind a singleton to the container using the `container.singleton` method. It is the same as the `container.bind` method, except the factory function is called only once, and the return value is cached forever. @@ -436,7 +537,7 @@ This is where the `@bind` decorator comes into the picture. To perform database If you are using the container inside a TypeScript project, then you can define the types for all the bindings in advance at the time of creating the container instance. -Defining types will ensure the `bind`, `singleton` and `bindValue` method accepts only the known bindings and assert their types as well. +Defining types will ensure the `bind`, `singleton`, `bindValue` and `if().bind()` methods accept only the known bindings and assert their types as well. ```ts class Route {} diff --git a/src/conditional_bindings_builder.ts b/src/conditional_bindings_builder.ts new file mode 100644 index 0000000..cbd99d2 --- /dev/null +++ b/src/conditional_bindings_builder.ts @@ -0,0 +1,136 @@ +/* + * @adonisjs/fold + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { AbstractConstructor } from '@poppinss/utils/types' + +import type { Container } from './container.ts' +import type { BindingCondition, BindingKey, BindingResolver } from './types.ts' + +/** + * A fluent builder to register conditional bindings with the + * container. + * + * Bindings registered using this builder are only used when the condition + * returns true. The condition is evaluated on every resolution. + */ +export class ConditionalBindingsBuilder> { + /** + * The condition deciding whether the registered bindings + * should be used + */ + #condition: BindingCondition + + /** + * Container instance for registering the conditional + * bindings + */ + #container: Container + + /** + * Initialize the conditional bindings builder + * + * @param condition - Predicate deciding whether the bindings should be used + * @param container - The container instance to register bindings with + */ + constructor(condition: BindingCondition, container: Container) { + this.#condition = condition + this.#container = container + } + + /** + * Register a binding to use when the condition returns true. The factory + * function is called on every resolution. + * + * @param binding - The binding key (string, symbol, or class constructor) + * @param resolver - Factory function that resolves the binding value + * + * @example + * ```ts + * container + * .if((resolver) => resolver.make(FeatureFlags).isEnabled('new-payment')) + * .bind(PaymentGateway, (resolver) => resolver.make(BetaPaymentGateway)) + * ``` + */ + bind( + /** + * Need to narrow down the "Binding" for the case where "KnownBindings" are + */ + binding: Binding extends string | symbol ? Binding : never, + resolver: BindingResolver + ): void + bind>( + binding: Binding, + resolver: BindingResolver> + ): void + bind( + binding: Binding, + resolver: BindingResolver< + KnownBindings, + Binding extends AbstractConstructor + ? A + : Binding extends keyof KnownBindings + ? KnownBindings[Binding] + : never + > + ): void { + this.#container.conditionalBinding( + this.#condition, + binding as BindingKey, + resolver as BindingResolver, + false + ) + } + + /** + * Register a singleton to use when the condition returns true. The factory + * function is called only once and the return value is cached forever. + * + * The condition is still evaluated on every resolution. Only the factory + * function result is cached. + * + * @param binding - The binding key (string, symbol, or class constructor) + * @param resolver - Factory function that resolves the binding value + * + * @example + * ```ts + * container + * .if(() => env.get('SEARCH_DRIVER') === 'typesense') + * .singleton(SearchService, (resolver) => resolver.make(TypesenseSearchService)) + * ``` + */ + singleton( + /** + * Need to narrow down the "Binding" for the case where "KnownBindings" are + */ + binding: Binding extends string | symbol ? Binding : never, + resolver: BindingResolver + ): void + singleton>( + binding: Binding, + resolver: BindingResolver> + ): void + singleton( + binding: Binding, + resolver: BindingResolver< + KnownBindings, + Binding extends AbstractConstructor + ? A + : Binding extends keyof KnownBindings + ? KnownBindings[Binding] + : never + > + ): void { + this.#container.conditionalBinding( + this.#condition, + binding as BindingKey, + resolver as BindingResolver, + true + ) + } +} diff --git a/src/container.ts b/src/container.ts index a86653c..56a4a8c 100644 --- a/src/container.ts +++ b/src/container.ts @@ -20,8 +20,10 @@ import type { ErrorCreator, HookCallback, BindingValues, + BindingCondition, BindingResolver, ContainerOptions, + ConditionalBindings, ContextualBindings, } from './types.ts' @@ -29,6 +31,7 @@ import debug from './debug.ts' import { enqueue, isClass } from './utils.ts' import { ContainerResolver } from './resolver.ts' import { ContextBindingsBuilder } from './contextual_bindings_builder.ts' +import { ConditionalBindingsBuilder } from './conditional_bindings_builder.ts' /** * The container class exposes the API to register bindings, values @@ -78,6 +81,12 @@ export class Container> { */ #bindings: Bindings = new Map() + /** + * Registered conditional bindings. A condition is evaluated by the resolver + * and therefore has access to values local to that resolver. + */ + #conditionalBindings: ConditionalBindings = new Map() + /** * Registered bindings as values. The values are preferred over the bindings. */ @@ -153,6 +162,7 @@ export class Container> { return new ContainerResolver( { bindings: this.#bindings, + conditionalBindings: this.#conditionalBindings, bindingValues: this.#bindingValues, swaps: this.#swaps, hooks: this.#hooks, @@ -164,8 +174,12 @@ export class Container> { } /** - * Find if the container has a binding registered using the - * "bind", the "singleton", or the "bindValue" methods. + * Find if the container has a binding registered using the "bind", the + * "singleton", the "bindValue", or the "if" methods. + * + * A registered binding is not always resolvable. When a binding key only + * has conditional bindings registered for it, this method returns true, + * but resolving it still fails if none of the conditions match. * * @param binding - The binding key to check for * @@ -179,13 +193,16 @@ export class Container> { hasBinding(binding: BindingKey): boolean hasBinding(binding: BindingKey): boolean { return ( - this.#aliases.has(binding) || this.#bindingValues.has(binding) || this.#bindings.has(binding) + this.#aliases.has(binding) || + this.#bindingValues.has(binding) || + this.#conditionalBindings.has(binding) || + this.#bindings.has(binding) ) } /** - * Find if the container has all the bindings registered using the - * "bind", the "singleton", or the "bindValue" methods. + * Find if the container has all the bindings registered using the "bind", + * the "singleton", the "bindValue", or the "if" methods. * * @param bindings - Array of binding keys to check for * @@ -617,6 +634,33 @@ export class Container> { return new ContextBindingsBuilder(parent, this) } + /** + * Create a conditional builder to define bindings that are only used + * when the condition returns true. The condition is evaluated on + * every resolution and receives the resolver performing it. + * + * Unlike contextual bindings, which are scoped to a parent class, conditional + * bindings apply to every resolution of the binding key. + * + * @param condition - Predicate deciding whether the bindings should be used + * + * @example + * ```ts + * container + * .if(async (resolver) => { + * if (!resolver.hasBinding(HttpContext)) { + * return false + * } + * const ctx = await resolver.make(HttpContext) + * return ctx.auth.user?.featureFlags.includes('new-payment') === true + * }) + * .bind(PaymentGateway, (resolver) => resolver.make(BetaPaymentGateway)) + * ``` + */ + if(condition: BindingCondition): ConditionalBindingsBuilder { + return new ConditionalBindingsBuilder(condition, this) + } + /** * Add a contextual binding for a given class constructor. A * contextual binding takes a parent, parent's dependency and a callback @@ -666,4 +710,58 @@ export class Container> { const parentBindings = this.#contextualBindings.get(parent)! parentBindings.set(binding, { resolver }) } + + /** + * Add a conditional binding for a given binding key. A conditional binding + * takes a condition, the binding key and a callback to resolve the value. + * + * A binding key may have multiple conditional bindings. They are evaluated + * in registration order and the first matching one is used. When none of + * them match, the resolution falls back to a regular binding or the + * default container behavior. + * + * @internal + * + * @param condition - Predicate deciding whether the binding should be used + * @param binding - The binding key (string, symbol, or class constructor) + * @param resolver - Factory function to resolve the binding value + * @param isSingleton - Cache the resolver result after the first resolution + * + * @example + * ```ts + * container.conditionalBinding( + * () => env.get('NODE_ENV') === 'staging', + * Mailer, + * () => new PreviewMailer() + * ) + * ``` + */ + conditionalBinding( + condition: BindingCondition, + binding: BindingKey, + resolver: BindingResolver, + isSingleton: boolean = false + ): void { + if (typeof binding !== 'string' && typeof binding !== 'symbol' && !isClass(binding)) { + throw new InvalidArgumentsException( + 'The container binding key must be of type "string", "symbol", or a "class constructor"' + ) + } + + debug('adding conditional binding to container %O', binding) + + /** + * Create the list for the binding if it doesn't already exists + */ + if (!this.#conditionalBindings.has(binding)) { + this.#conditionalBindings.set(binding, []) + } + + const bindings = this.#conditionalBindings.get(binding)! + bindings.push( + isSingleton + ? { condition, resolver: enqueue(resolver), isSingleton: true } + : { condition, resolver, isSingleton: false } + ) + } } diff --git a/src/resolver.ts b/src/resolver.ts index 7627344..5ad4d2d 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -17,10 +17,12 @@ import type { Swaps, Bindings, BindingKey, + BindingEntry, ErrorCreator, BindingValues, BindingResolver, ContainerOptions, + ConditionalBindings, ContextualBindings, InspectableConstructor, } from './types.ts' @@ -70,6 +72,12 @@ export class ContainerResolver> { */ #containerBindings: Bindings + /** + * Pre-registered conditional bindings. They are shared between the container + * and resolver and evaluated using this resolver. + */ + #containerConditionalBindings: ConditionalBindings + /** * Pre-registered bindings. They are shared between the container * and resolver. @@ -111,6 +119,7 @@ export class ContainerResolver> { constructor( container: { bindings: Bindings + conditionalBindings?: ConditionalBindings bindingValues: BindingValues swaps: Swaps hooks: Hooks @@ -120,6 +129,7 @@ export class ContainerResolver> { options: ContainerOptions ) { this.#containerBindings = container.bindings + this.#containerConditionalBindings = container.conditionalBindings || new Map() this.#containerBindingValues = container.bindingValues this.#containerSwaps = container.swaps this.#containerHooks = container.hooks @@ -234,6 +244,57 @@ export class ContainerResolver> { } } + /** + * Resolves a registered binding entry to its value. The entry may either + * come from the regular bindings or from a matched conditional binding, + * both of which share the same shape and therefore the same singleton + * and hooks behavior. + * + * @param binding - The binding key being resolved + * @param entry - The registered binding entry + * @param runtimeValues - Optional runtime values for dependencies + */ + async #resolveBindingEntry(binding: BindingKey, entry: BindingEntry, runtimeValues?: any[]) { + let value + let executeHooks = true + + /** + * Invoke binding resolver to get the value. In case of singleton, + * the "enqueue" method returns an object with the value and a + * boolean telling if a cached value is resolved. + */ + if (entry.isSingleton) { + const result = await entry.resolver(this, runtimeValues) + value = result.value + executeHooks = !result.cached + } else { + value = await entry.resolver(this, runtimeValues) + } + + if (executeHooks) { + const hooksPromise = this.#execHooks(binding, value) + + /** + * if singleton, store the hooks promise that will be awaited for subsequent resolutions + */ + if (entry.isSingleton) { + entry.hooksPromise = hooksPromise.then(() => { + delete entry.hooksPromise + }) + } + + await hooksPromise + } + + if (entry.isSingleton && entry.hooksPromise) { + await entry.hooksPromise + } + + this.#emit(binding, value) + + return value + } + /** * Resolves binding in context of a parent. The method is same as * the "make" method, but instead takes a parent class @@ -328,50 +389,40 @@ export class ContainerResolver> { } /** - * Followed by the CONTAINER bindings + * Followed by CONDITIONAL CONTAINER bindings. Conditions are evaluated in + * registration order and the first matching resolver is used. + * + * The condition receives this resolver alongside the parent asking for the + * binding, so it can decide using values local to the resolver, or the + * class the binding is getting injected into. */ - if (this.#containerBindings.has(binding)) { - const containerBinding = this.#containerBindings.get(binding)! - let value - let executeHooks = true - - /** - * Invoke binding resolver to get the value. In case of singleton, - * the "enqueue" method returns an object with the value and a - * boolean telling if a cached value is resolved. - */ - if (containerBinding.isSingleton) { - const result = await containerBinding.resolver(this, runtimeValues) - value = result.value - executeHooks = !result.cached - } else { - value = await containerBinding.resolver(this, runtimeValues) - } - - if (debug.enabled) { - debug('resolved binding %O, resolved value :%O', binding, value) - } + const conditionalBindings = this.#containerConditionalBindings.get(binding) + if (conditionalBindings) { + for (const conditionalBinding of conditionalBindings) { + if (!(await conditionalBinding.condition(this, runtimeValues, parent))) { + continue + } - if (executeHooks) { - const hooksPromise = this.#execHooks(binding, value) + const value = await this.#resolveBindingEntry(binding, conditionalBinding, runtimeValues) - /** - * if singleton, store the hooks promise that will be awaited for subsequent resolutions - */ - if (containerBinding.isSingleton) { - containerBinding.hooksPromise = hooksPromise.then(() => { - delete containerBinding.hooksPromise - }) + if (debug.enabled) { + debug('resolved conditional binding %O, resolved value :%O', binding, value) } - await hooksPromise + return value } + } - if (containerBinding.isSingleton && containerBinding.hooksPromise) { - await containerBinding.hooksPromise - } + /** + * Followed by the CONTAINER bindings + */ + if (this.#containerBindings.has(binding)) { + const containerBinding = this.#containerBindings.get(binding)! + const value = await this.#resolveBindingEntry(binding, containerBinding, runtimeValues) - this.#emit(binding, value) + if (debug.enabled) { + debug('resolved binding %O, resolved value :%O', binding, value) + } return value } @@ -422,12 +473,27 @@ export class ContainerResolver> { return value } - throw createError(`Cannot resolve binding "${String(binding)}" from the container`) + const error = createError(`Cannot resolve binding "${String(binding)}" from the container`) + + /** + * The binding only has conditional bindings registered for it and none of + * their conditions matched. Point to the missing fallback, otherwise the + * error reads like the binding was never registered at all. + */ + if (conditionalBindings?.length) { + error.help = `The binding has ${conditionalBindings.length} conditional binding(s) registered, but none of their conditions returned true. Register a fallback using the "container.bind()" method` + } + + throw error } /** - * Find if the resolver has a binding registered using the - * "bind", the "singleton", or the "bindValue" methods. + * Find if the resolver has a binding registered using the "bind", the + * "singleton", the "bindValue", or the "if" methods. + * + * A registered binding is not always resolvable. When a binding key only + * has conditional bindings registered for it, this method returns true, + * but resolving it still fails if none of the conditions match. * * @param binding - The binding key to check for * @@ -444,13 +510,14 @@ export class ContainerResolver> { this.#containerAliases.has(binding) || this.#bindingValues.has(binding) || this.#containerBindingValues.has(binding) || + this.#containerConditionalBindings.has(binding) || this.#containerBindings.has(binding) ) } /** - * Find if the resolver has all the bindings registered using the - * "bind", the "singleton", or the "bindValue" methods. + * Find if the resolver has all the bindings registered using the "bind", + * the "singleton", the "bindValue", or the "if" methods. * * @param bindings - Array of binding keys to check for * diff --git a/src/types.ts b/src/types.ts index 1c4f2b2..1228dde 100644 --- a/src/types.ts +++ b/src/types.ts @@ -67,12 +67,32 @@ export type BindingResolver, Value> = ( ) => Value | Promise /** - * Shape of the registered bindings + * Shape of a condition used by conditional bindings * - * Map structure containing binding keys and their resolver configurations + * @template KnownBindings - Known bindings record type + * @param resolver - Container resolver instance + * @param runtimeValues - Optional runtime values. They are only available when + * the binding is resolved directly via "make" or "call". Dependencies + * resolved for a class receive "undefined" + * @param parent - The class asking for this binding, or "null" when the binding + * is resolved directly via "make" + * @returns Whether the associated binding resolver should be used */ -export type Bindings = Map< - BindingKey, +export type BindingCondition> = ( + resolver: ContainerResolver, + runtimeValues?: any[], + parent?: unknown +) => boolean | Promise + +/** + * Shape of a single registered binding. The entry holds the resolver + * alongside the flag to know if the resolved value must be cached + * after the first resolution. + * + * The same entry shape is used by the regular and the conditional bindings, + * so both get identical singleton and hooks behavior. + */ +export type BindingEntry = | { resolver: BindingResolver, any>; isSingleton: false } | { resolver: ( @@ -82,6 +102,23 @@ export type Bindings = Map< isSingleton: true hooksPromise?: Promise } + +/** + * Shape of the registered bindings + * + * Map structure containing binding keys and their resolver configurations + */ +export type Bindings = Map + +/** + * Shape of the registered conditional bindings + * + * A binding key may have multiple conditional bindings. The conditions are + * evaluated in registration order and the first matching entry is used. + */ +export type ConditionalBindings = Map< + BindingKey, + ({ condition: BindingCondition> } & BindingEntry)[] > /** diff --git a/tests/container/bindings.spec.ts b/tests/container/bindings.spec.ts index 23dd999..15b4e84 100644 --- a/tests/container/bindings.spec.ts +++ b/tests/container/bindings.spec.ts @@ -134,6 +134,346 @@ test.group('Container | Bindings', () => { }) }) +test.group('Container | Conditional bindings', () => { + abstract class PaymentGateway { + abstract name: string + } + + class StripePaymentGateway implements PaymentGateway { + name = 'stripe' + } + + class BetaPaymentGateway implements PaymentGateway { + name = 'beta' + } + + class FeatureFlags { + constructor(public flags: string[]) {} + } + + test('use a conditional binding when its condition matches', async ({ assert }) => { + const container = new Container() + const resolver = container.createResolver() + + container.bind(PaymentGateway, () => new StripePaymentGateway()) + container + .if(async (currentResolver) => { + const featureFlags = await currentResolver.make(FeatureFlags) + return featureFlags.flags.includes('new-payment') + }) + .bind(PaymentGateway, () => new BetaPaymentGateway()) + resolver.bindValue(FeatureFlags, new FeatureFlags(['new-payment'])) + + const paymentGateway = await resolver.make(PaymentGateway) + + expectTypeOf(paymentGateway).toEqualTypeOf() + assert.instanceOf(paymentGateway, BetaPaymentGateway) + }) + + test('evaluate the condition using values local to each resolver', async ({ assert }) => { + const container = new Container() + const resolver = container.createResolver() + const betaResolver = container.createResolver() + + container.bind(PaymentGateway, () => new StripePaymentGateway()) + container + .if(async (currentResolver) => { + const featureFlags = await currentResolver.make(FeatureFlags) + return featureFlags.flags.includes('new-payment') + }) + .bind(PaymentGateway, () => new BetaPaymentGateway()) + resolver.bindValue(FeatureFlags, new FeatureFlags([])) + betaResolver.bindValue(FeatureFlags, new FeatureFlags(['new-payment'])) + + assert.instanceOf(await resolver.make(PaymentGateway), StripePaymentGateway) + assert.instanceOf(await betaResolver.make(PaymentGateway), BetaPaymentGateway) + }) + + test('use the first matching conditional binding', async ({ assert }) => { + const container = new Container() + const invocations: string[] = [] + + container + .if(() => { + invocations.push('first') + return true + }) + .bind(PaymentGateway, () => new BetaPaymentGateway()) + container + .if(() => { + invocations.push('second') + return true + }) + .bind(PaymentGateway, () => new StripePaymentGateway()) + + assert.instanceOf(await container.make(PaymentGateway), BetaPaymentGateway) + assert.deepEqual(invocations, ['first']) + }) + + test('fall back to regular resolution when no condition matches', async ({ assert }) => { + const container = new Container() + + container.bind(PaymentGateway, () => new StripePaymentGateway()) + container.if(() => false).bind(PaymentGateway, () => new BetaPaymentGateway()) + + assert.instanceOf(await container.make(PaymentGateway), StripePaymentGateway) + }) + + test('run hooks for conditional bindings', async ({ assert }) => { + const container = new Container() + + container.if(() => true).bind(PaymentGateway, () => new BetaPaymentGateway()) + container.resolving(PaymentGateway, (paymentGateway) => { + paymentGateway.name = 'hooked' + }) + + const paymentGateway = await container.make(PaymentGateway) + + assert.equal(paymentGateway.name, 'hooked') + }) + + test('report conditional bindings as registered bindings', ({ assert }) => { + const container = new Container() + + container.if(() => false).bind(PaymentGateway, () => new BetaPaymentGateway()) + + assert.isTrue(container.hasBinding(PaymentGateway)) + assert.isTrue(container.createResolver().hasBinding(PaymentGateway)) + }) + + test('use a string as the conditional binding key', async ({ assert }) => { + const container = new Container() + + container.bind('gateway', () => new StripePaymentGateway()) + container.if(() => true).bind('gateway', () => new BetaPaymentGateway()) + + assert.instanceOf(await container.make('gateway'), BetaPaymentGateway) + }) + + test('use a symbol as the conditional binding key', async ({ assert }) => { + const container = new Container() + const gateway = Symbol('gateway') + + container.bind(gateway, () => new StripePaymentGateway()) + container.if(() => true).bind(gateway, () => new BetaPaymentGateway()) + + assert.instanceOf(await container.make(gateway), BetaPaymentGateway) + }) + + test('propagate the error thrown by a condition', async ({ assert }) => { + const container = new Container() + + container.bind(PaymentGateway, () => new StripePaymentGateway()) + container + .if(() => { + throw new Error('Cannot read the feature flags') + }) + .bind(PaymentGateway, () => new BetaPaymentGateway()) + + await assert.rejects(() => container.make(PaymentGateway), 'Cannot read the feature flags') + }) + + test('give precedence to swaps over conditional bindings', async ({ assert }) => { + const container = new Container() + + container.if(() => true).bind(PaymentGateway, () => new BetaPaymentGateway()) + container.swap(PaymentGateway, () => new StripePaymentGateway()) + + assert.instanceOf(await container.make(PaymentGateway), StripePaymentGateway) + + container.restore(PaymentGateway) + assert.instanceOf(await container.make(PaymentGateway), BetaPaymentGateway) + }) + + test('give precedence to contextual bindings over conditional bindings', async ({ assert }) => { + const container = new Container() + + class CheckoutController { + static containerInjections = { _constructor: { dependencies: [PaymentGateway] } } + constructor(public paymentGateway: PaymentGateway) {} + } + + container.if(() => true).bind(PaymentGateway, () => new BetaPaymentGateway()) + container + .when(CheckoutController) + .asksFor(PaymentGateway) + .provide(() => new StripePaymentGateway()) + + const controller = await container.make(CheckoutController) + + assert.instanceOf(controller.paymentGateway, StripePaymentGateway) + assert.instanceOf(await container.make(PaymentGateway), BetaPaymentGateway) + }) + + test('give precedence to container values over conditional bindings', async ({ assert }) => { + const container = new Container() + const gateway = new StripePaymentGateway() + + container.if(() => true).bind(PaymentGateway, () => new BetaPaymentGateway()) + container.bindValue(PaymentGateway, gateway) + + assert.strictEqual(await container.make(PaymentGateway), gateway) + }) + + test('give precedence to resolver values over conditional bindings', async ({ assert }) => { + const container = new Container() + const gateway = new StripePaymentGateway() + + container.if(() => true).bind(PaymentGateway, () => new BetaPaymentGateway()) + + const resolver = container.createResolver() + resolver.bindValue(PaymentGateway, gateway) + + assert.strictEqual(await resolver.make(PaymentGateway), gateway) + assert.instanceOf(await container.make(PaymentGateway), BetaPaymentGateway) + }) + + test('explain unmatched conditions when resolution fails', async ({ assert }) => { + const container = new Container() + + container.if(() => false).bind('gateway', () => new BetaPaymentGateway()) + container.if(() => false).bind('gateway', () => new StripePaymentGateway()) + + assert.isTrue(container.hasBinding('gateway')) + + try { + await container.make('gateway') + assert.fail('Expected the resolution to fail') + } catch (error) { + assert.equal(error.message, 'Cannot resolve binding "gateway" from the container') + assert.equal( + error.help, + 'The binding has 2 conditional binding(s) registered, but none of their conditions returned true. Register a fallback using the "container.bind()" method' + ) + } + }) + + test('do not explain unmatched conditions for an unregistered binding', async ({ assert }) => { + const container = new Container() + + try { + await container.make('gateway') + assert.fail('Expected the resolution to fail') + } catch (error) { + assert.equal(error.message, 'Cannot resolve binding "gateway" from the container') + assert.isUndefined(error.help) + } + }) + + test('disallow invalid conditional binding names', ({ assert }) => { + const container = new Container() + + assert.throws( + () => + container + .if(() => true) + .bind( + // @ts-expect-error + 1, + () => new BetaPaymentGateway() + ), + 'The container binding key must be of type "string", "symbol", or a "class constructor"' + ) + }) + + test('receive the parent asking for the binding inside the condition', async ({ assert }) => { + const container = new Container() + const parents: unknown[] = [] + + class CheckoutController { + static containerInjections = { _constructor: { dependencies: [PaymentGateway] } } + constructor(public paymentGateway: PaymentGateway) {} + } + + container.bind(PaymentGateway, () => new StripePaymentGateway()) + container + .if((_, __, parent) => { + parents.push(parent) + return parent === CheckoutController + }) + .bind(PaymentGateway, () => new BetaPaymentGateway()) + + const controller = await container.make(CheckoutController) + const standalone = await container.make(PaymentGateway) + + assert.instanceOf(controller.paymentGateway, BetaPaymentGateway) + assert.instanceOf(standalone, StripePaymentGateway) + assert.deepEqual(parents, [CheckoutController, null]) + }) + + test('cache the value of a conditional singleton', async ({ assert }) => { + const container = new Container() + let invocations = 0 + + container + .if(() => true) + .singleton(PaymentGateway, () => { + invocations++ + return new BetaPaymentGateway() + }) + + const first = await container.make(PaymentGateway) + const second = await container.make(PaymentGateway) + + assert.strictEqual(first, second) + assert.equal(invocations, 1) + }) + + test('evaluate the condition of a singleton on every resolution', async ({ assert }) => { + const container = new Container() + let useBeta = true + let conditions = 0 + + container.bind(PaymentGateway, () => new StripePaymentGateway()) + container + .if(() => { + conditions++ + return useBeta + }) + .singleton(PaymentGateway, () => new BetaPaymentGateway()) + + const beta = await container.make(PaymentGateway) + useBeta = false + const stripe = await container.make(PaymentGateway) + + assert.instanceOf(beta, BetaPaymentGateway) + assert.instanceOf(stripe, StripePaymentGateway) + assert.equal(conditions, 2) + }) + + test('run hooks only once for a conditional singleton', async ({ assert }) => { + const container = new Container() + let hooks = 0 + + container.if(() => true).singleton(PaymentGateway, () => new BetaPaymentGateway()) + container.resolving(PaymentGateway, () => { + hooks++ + }) + + await container.make(PaymentGateway) + await container.make(PaymentGateway) + + assert.equal(hooks, 1) + }) + + test('receive runtime values inside the condition', async ({ assert }) => { + const container = new Container() + const received: (any[] | undefined)[] = [] + + container.bind(PaymentGateway, () => new StripePaymentGateway()) + container + .if((_, runtimeValues) => { + received.push(runtimeValues) + return runtimeValues?.[0] === 'beta' + }) + .bind(PaymentGateway, () => new BetaPaymentGateway()) + + assert.instanceOf(await container.make(PaymentGateway, ['beta']), BetaPaymentGateway) + assert.instanceOf(await container.make(PaymentGateway), StripePaymentGateway) + assert.deepEqual(received, [['beta'], undefined]) + }) +}) + test.group('Container | Bindings Singleton', () => { test('register a singleton to the container', async ({ assert }) => { const container = new Container() diff --git a/tests/container/events.spec.ts b/tests/container/events.spec.ts index 27a69d3..340a05f 100644 --- a/tests/container/events.spec.ts +++ b/tests/container/events.spec.ts @@ -162,6 +162,51 @@ test.group('Container | Events', () => { assert.deepEqual(event, { binding: Route, value: route }) }) + test('emit event when a conditional binding is resolved', async ({ assert }) => { + const emitter = new EventEmitter() + const container = new Container({ emitter }) + class Route {} + + container + .if(() => true) + .bind('route', () => { + return new Route() + }) + + const [event, route] = await Promise.all([ + pEvent(emitter, 'container_binding:resolved'), + container.make('route'), + ]) + + expectTypeOf(route).toBeAny() + assert.instanceOf(route, Route) + assert.deepEqual(event, { binding: 'route', value: route }) + }) + + test('emit event when a conditional singleton is resolved multiple times', async ({ assert }) => { + const emitter = new EventEmitter() + const container = new Container({ emitter }) + class Route {} + + container + .if(() => true) + .singleton('route', () => { + return new Route() + }) + + const [events, route, route1] = await Promise.all([ + pEventMultiple(emitter, 'container_binding:resolved', { count: 2 }), + container.make('route'), + container.make('route'), + ]) + + assert.strictEqual(route, route1) + assert.deepEqual(events, [ + { binding: 'route', value: route }, + { binding: 'route', value: route }, + ]) + }) + test('register emitter using the useEmitter method', async ({ assert }) => { const emitter = new EventEmitter() const container = new Container() diff --git a/tests/container/known_bindings.spec.ts b/tests/container/known_bindings.spec.ts index ab05ed2..32251ff 100644 --- a/tests/container/known_bindings.spec.ts +++ b/tests/container/known_bindings.spec.ts @@ -10,6 +10,7 @@ import { test } from '@japa/runner' import { expectTypeOf } from 'expect-type' import { Container } from '../../src/container.ts' +import type { ContainerResolver } from '../../src/resolver.ts' test.group('Container | Bindings', () => { test('register a binding to the container', async ({ assert }) => { @@ -357,6 +358,82 @@ test.group('Container | Aliases', () => { assert.isFalse(container.hasBinding('db')) }) + test('assert types of a conditional binding', async ({ assert }) => { + const container = new Container<{ route: Route }>() + class Route {} + + container + .if(() => true) + .bind('route', () => { + return new Route() + }) + + const route = await container.make('route') + + expectTypeOf(route).toEqualTypeOf() + assert.instanceOf(route, Route) + }) + + test('assert types of a conditional singleton', async ({ assert }) => { + const container = new Container<{ route: Route }>() + class Route {} + + container + .if(() => true) + .singleton('route', () => { + return new Route() + }) + + const route = await container.make('route') + + expectTypeOf(route).toEqualTypeOf() + assert.instanceOf(route, Route) + }) + + test('disallow unknown bindings and mismatched values in conditional bindings', async ({ + assert, + }) => { + const container = new Container<{ route: Route }>() + + /** + * Both classes need a distinguishing member, otherwise they are + * structurally identical and assignable to each other + */ + class Route { + isRoute = true + } + class Database { + isDatabase = true + } + + // @ts-expect-error "db" is not a known binding + container.if(() => true).bind('db', () => new Database()) + + // @ts-expect-error Database is not assignable to Route + container.if(() => true).bind('route', () => new Database()) + + // @ts-expect-error Database is not assignable to Route + container.if(() => true).singleton('route', () => new Database()) + + assert.isTrue(container.hasBinding('route')) + }) + + test('assert types of the condition arguments', async ({ assert }) => { + const container = new Container<{ route: Route }>() + class Route {} + + container + .if((resolver, runtimeValues, parent) => { + expectTypeOf(resolver).toEqualTypeOf>() + expectTypeOf(runtimeValues).toEqualTypeOf() + expectTypeOf(parent).toEqualTypeOf() + return true + }) + .bind('route', () => new Route()) + + assert.instanceOf(await container.make('route'), Route) + }) + test('return true from hasAllBindings when checking for alias', async ({ assert }) => { const routeSymbol = Symbol('route')