From 7512105ff1a0f0642b0a02cb1d5574d74a9d68a8 Mon Sep 17 00:00:00 2001 From: kodemon Date: Mon, 3 Jun 2024 17:12:43 +0200 Subject: [PATCH] feat(release): initial release --- .github/workflows/publish.yml | 30 ++++++ .github/workflows/test.yml | 18 ++++ .vscode/settings.json | 8 ++ deno.json | 22 +++++ deno.lock | 40 ++++++++ libraries/container.ts | 115 ++++++++++++++++++++++ libraries/errors.ts | 17 ++++ mod.ts | 1 + tests/container.test.ts | 131 ++++++++++++++++++++++++++ tests/mocks/providers/invoice-2-go.ts | 5 + tests/mocks/providers/paypal.ts | 14 +++ tests/mocks/providers/stripe.ts | 14 +++ tests/mocks/services/invoices.ts | 5 + tests/mocks/services/logger.ts | 1 + tests/mocks/services/payments.ts | 22 +++++ 15 files changed, 443 insertions(+) create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/test.yml create mode 100644 .vscode/settings.json create mode 100644 deno.json create mode 100644 deno.lock create mode 100644 libraries/container.ts create mode 100644 libraries/errors.ts create mode 100644 mod.ts create mode 100644 tests/container.test.ts create mode 100644 tests/mocks/providers/invoice-2-go.ts create mode 100644 tests/mocks/providers/paypal.ts create mode 100644 tests/mocks/providers/stripe.ts create mode 100644 tests/mocks/services/invoices.ts create mode 100644 tests/mocks/services/logger.ts create mode 100644 tests/mocks/services/payments.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..361379f --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,30 @@ +name: Publish + +on: + release: + types: [published] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Deno + uses: maximousblk/setup-deno@v2 # Installs latest version + + - run: deno test + + publish: + runs-on: ubuntu-latest + needs: test + + permissions: + contents: read + id-token: write + + steps: + - uses: actions/checkout@v4 + + - name: Publish package + run: npx jsr publish \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..92e36dd --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,18 @@ +name: Test + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Deno + uses: maximousblk/setup-deno@v2 # Installs latest version + + - run: deno test \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..bc7c033 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "deno.enable": true, + "editor.formatOnSave": true, + "editor.defaultFormatter": "denoland.vscode-deno", + "editor.codeActionsOnSave": { + "source.fixAll": "explicit" + } +} \ No newline at end of file diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..7bf6838 --- /dev/null +++ b/deno.json @@ -0,0 +1,22 @@ +{ + "name": "@valkyr/inverse", + "version": "1.0.0", + "exports": "./mod.ts", + "imports": { + "std/": "https://deno.land/std@0.224.0/" + }, + "exclude": [ + ".vscode" + ], + "lint": { + "rules": { + "exclude": [ + "no-explicit-any", + "require-await" + ] + } + }, + "fmt": { + "lineWidth": 120 + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..8a433c6 --- /dev/null +++ b/deno.lock @@ -0,0 +1,40 @@ +{ + "version": "3", + "remote": { + "https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", + "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", + "https://deno.land/std@0.224.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293", + "https://deno.land/std@0.224.0/assert/assert_array_includes.ts": "14c5094471bc8e4a7895fc6aa5a184300d8a1879606574cb1cd715ef36a4a3c7", + "https://deno.land/std@0.224.0/assert/assert_equals.ts": "3bbca947d85b9d374a108687b1a8ba3785a7850436b5a8930d81f34a32cb8c74", + "https://deno.land/std@0.224.0/assert/assert_exists.ts": "43420cf7f956748ae6ed1230646567b3593cb7a36c5a5327269279c870c5ddfd", + "https://deno.land/std@0.224.0/assert/assert_false.ts": "3e9be8e33275db00d952e9acb0cd29481a44fa0a4af6d37239ff58d79e8edeff", + "https://deno.land/std@0.224.0/assert/assert_greater.ts": "5e57b201fd51b64ced36c828e3dfd773412c1a6120c1a5a99066c9b261974e46", + "https://deno.land/std@0.224.0/assert/assert_greater_or_equal.ts": "9870030f997a08361b6f63400273c2fb1856f5db86c0c3852aab2a002e425c5b", + "https://deno.land/std@0.224.0/assert/assert_instance_of.ts": "e22343c1fdcacfaea8f37784ad782683ec1cf599ae9b1b618954e9c22f376f2c", + "https://deno.land/std@0.224.0/assert/assert_is_error.ts": "f856b3bc978a7aa6a601f3fec6603491ab6255118afa6baa84b04426dd3cc491", + "https://deno.land/std@0.224.0/assert/assert_less.ts": "60b61e13a1982865a72726a5fa86c24fad7eb27c3c08b13883fb68882b307f68", + "https://deno.land/std@0.224.0/assert/assert_less_or_equal.ts": "d2c84e17faba4afe085e6c9123a63395accf4f9e00150db899c46e67420e0ec3", + "https://deno.land/std@0.224.0/assert/assert_match.ts": "ace1710dd3b2811c391946954234b5da910c5665aed817943d086d4d4871a8b7", + "https://deno.land/std@0.224.0/assert/assert_not_equals.ts": "78d45dd46133d76ce624b2c6c09392f6110f0df9b73f911d20208a68dee2ef29", + "https://deno.land/std@0.224.0/assert/assert_not_instance_of.ts": "3434a669b4d20cdcc5359779301a0588f941ffdc2ad68803c31eabdb4890cf7a", + "https://deno.land/std@0.224.0/assert/assert_not_match.ts": "df30417240aa2d35b1ea44df7e541991348a063d9ee823430e0b58079a72242a", + "https://deno.land/std@0.224.0/assert/assert_not_strict_equals.ts": "37f73880bd672709373d6dc2c5f148691119bed161f3020fff3548a0496f71b8", + "https://deno.land/std@0.224.0/assert/assert_object_match.ts": "411450fd194fdaabc0089ae68f916b545a49d7b7e6d0026e84a54c9e7eed2693", + "https://deno.land/std@0.224.0/assert/assert_rejects.ts": "4bee1d6d565a5b623146a14668da8f9eb1f026a4f338bbf92b37e43e0aa53c31", + "https://deno.land/std@0.224.0/assert/assert_strict_equals.ts": "b4f45f0fd2e54d9029171876bd0b42dd9ed0efd8f853ab92a3f50127acfa54f5", + "https://deno.land/std@0.224.0/assert/assert_string_includes.ts": "496b9ecad84deab72c8718735373feb6cdaa071eb91a98206f6f3cb4285e71b8", + "https://deno.land/std@0.224.0/assert/assert_throws.ts": "c6508b2879d465898dab2798009299867e67c570d7d34c90a2d235e4553906eb", + "https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", + "https://deno.land/std@0.224.0/assert/equal.ts": "bddf07bb5fc718e10bb72d5dc2c36c1ce5a8bdd3b647069b6319e07af181ac47", + "https://deno.land/std@0.224.0/assert/fail.ts": "0eba674ffb47dff083f02ced76d5130460bff1a9a68c6514ebe0cdea4abadb68", + "https://deno.land/std@0.224.0/assert/mod.ts": "48b8cb8a619ea0b7958ad7ee9376500fe902284bb36f0e32c598c3dc34cbd6f3", + "https://deno.land/std@0.224.0/assert/unimplemented.ts": "8c55a5793e9147b4f1ef68cd66496b7d5ba7a9e7ca30c6da070c1a58da723d73", + "https://deno.land/std@0.224.0/assert/unreachable.ts": "5ae3dbf63ef988615b93eb08d395dda771c96546565f9e521ed86f6510c29e19", + "https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5", + "https://deno.land/std@0.224.0/internal/diff.ts": "6234a4b493ebe65dc67a18a0eb97ef683626a1166a1906232ce186ae9f65f4e6", + "https://deno.land/std@0.224.0/internal/format.ts": "0a98ee226fd3d43450245b1844b47003419d34d210fa989900861c79820d21c2", + "https://deno.land/std@0.224.0/internal/mod.ts": "534125398c8e7426183e12dc255bb635d94e06d0f93c60a297723abe69d3b22e", + "https://deno.land/std@0.224.0/testing/_test_suite.ts": "f10a8a6338b60c403f07a76f3f46bdc9f1e1a820c0a1decddeb2949f7a8a0546", + "https://deno.land/std@0.224.0/testing/bdd.ts": "3e4de4ff6d8f348b5574661cef9501b442046a59079e201b849d0e74120d476b" + } +} diff --git a/libraries/container.ts b/libraries/container.ts new file mode 100644 index 0000000..e5b88dd --- /dev/null +++ b/libraries/container.ts @@ -0,0 +1,115 @@ +import { MissingChildContainerError, MissingDependencyError } from "./errors.ts"; + +export class Container, C extends JSON = JSON> { + readonly providers: Map = new Map(); + readonly contexts: Map> = new Map(); + + /** + * A simple dependency injection service using inversion of control principles + * allowing the developer to program against TypeScript types or interfaces + * with implementation details injected by service providers. + * + * @param id - Container identifier used for easier debugging. + */ + constructor(readonly id: string) {} + + /* + |-------------------------------------------------------------------------------- + | Contexts + |-------------------------------------------------------------------------------- + | + | A container can have one or more contexts which represents a cloned version of + | the parent container. A context container is usually useful for when you want + | different types of the same provider to exist within the same dependency scope + | under a unique filterable context. + | + */ + + /** + * Create a new container with the given context object. A context is an object + * we provide to the .where method used to query the container assigned to the + * given context object. + * + * @param context - Context object used to filter future .where requests. + */ + createContext(context: C): Container { + return this.contexts.set(context, new Container(this.id)).get( + context, + ) as Container; + } + + /** + * Create or retrieve a container based on a specific context container. + * + * @param filter - Method which receives the container context object used to + * filter the specific container we want to operate on. + */ + where(filter: Filter): Container { + for (const context of Array.from(this.contexts.keys())) { + if (filter(context)) { + return this.contexts.get(context) as Container; + } + } + throw new MissingChildContainerError(this.id); + } + + /* + |-------------------------------------------------------------------------------- + | Utilities + |-------------------------------------------------------------------------------- + */ + + /** + * Check if a token has been registered in the singleton or transient map + * of the container. + * + * @param token - Token to verify. + */ + has(token: K): boolean { + return this.providers.has(token); + } + + /** + * Register a transient or singleton provider against the provided token. + * + * @param token - Token to register. + * @param provider - Provider to register under the given token. + */ + set(token: K, provider: T[K]): this { + this.providers.set(token, provider); + return this; + } + + /** + * Get a transient or singleton provider instance. + * + * @param token - Token to retrieve dependency for. + * @param args - Arguments to pass to a transient provider. + */ + get(token: K): T[K] { + const provider = this.providers.get(token); + if (!provider) { + throw new MissingDependencyError(this.id, token); + } + return provider as T[K]; + } + + new( + token: K, + ...args: ConstructorParameters + ): InstanceType { + const provider = this.providers.get(token); + if (!provider) { + throw new MissingDependencyError(this.id, token); + } + return new (provider as any)(...(args as any)); + } +} + +export type Tokens = { + [K in keyof T]: T[K]; +}; + +export type Filter = (context: T) => boolean; + +export type JSON = Record; diff --git a/libraries/errors.ts b/libraries/errors.ts new file mode 100644 index 0000000..a40fdc3 --- /dev/null +++ b/libraries/errors.ts @@ -0,0 +1,17 @@ +export class MissingChildContainerError extends Error { + public readonly type = "MissingChildContainerError" as const; + + constructor(id: string) { + super(`Dependency Violation: '${id}' container failed to resolve unregistered sub container`); + } +} + +export class MissingDependencyError extends Error { + public readonly type = "MissingDependencyError" as const; + + constructor(id: string, token: string | number | symbol) { + super( + `Dependency Violation: '${id}' container failed to resolve unregistered dependency token: ${token.toString()}`, + ); + } +} diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..e23e3a7 --- /dev/null +++ b/mod.ts @@ -0,0 +1 @@ +export { Container } from "./libraries/container.ts"; diff --git a/tests/container.test.ts b/tests/container.test.ts new file mode 100644 index 0000000..2b0259f --- /dev/null +++ b/tests/container.test.ts @@ -0,0 +1,131 @@ +import { assertEquals, assertExists, assertInstanceOf, assertObjectMatch, assertThrows } from "std/assert/mod.ts"; +import { beforeAll, beforeEach, describe, it } from "std/testing/bdd.ts"; + +import { Container } from "../libraries/container.ts"; +import { MissingChildContainerError, MissingDependencyError } from "../libraries/errors.ts"; +import { Invoice2Go } from "./mocks/providers/invoice-2-go.ts"; +import { PayPal } from "./mocks/providers/paypal.ts"; +import { Stripe } from "./mocks/providers/stripe.ts"; +import { Invoices } from "./mocks/services/invoices.ts"; +import { Logger } from "./mocks/services/logger.ts"; +import { Payments } from "./mocks/services/payments.ts"; + +type Context = { + provider: string; +}; + +type Tokens = { + logger: typeof Logger; + invoices: typeof Invoices; + payments: Payments; +}; + +const CONTAINER_ID = "mock"; + +const isProvider = (provider: string) => (context: Context) => provider === context.provider; + +describe("Inverse Module > Container", () => { + describe("when .where method is used", () => { + const paypal: Context = { provider: "paypal" }; + const stripe: Context = { provider: "stripe" }; + + let container: Container; + + beforeEach(() => { + container = new Container(CONTAINER_ID); + container.createContext(paypal); + container.createContext(stripe); + }); + + it("should add a sub container when argument is a context object", () => { + assertExists(container.contexts.get(paypal)); + assertExists(container.contexts.get(stripe)); + assertEquals(container.contexts.get({ provider: "skrill" }), undefined); + }); + + it("should set a sub container dependency when a filter method is provided", async () => { + container.where(isProvider("paypal")).set("payments", new PayPal()); + container.where(isProvider("stripe")).set("payments", new Stripe()); + + assertObjectMatch( + await container.where(isProvider("paypal")).get("payments").create("xyz", "usd", 100), + { + customerId: "xyz", + provider: "paypal", + currency: "usd", + amount: 100, + }, + ); + + assertObjectMatch( + await container.where(isProvider("stripe")).get("payments").create("xyz", "jpy", 15000), + { + customerId: "xyz", + provider: "stripe", + currency: "jpy", + amount: 15000, + }, + ); + }); + + it("should throw error when sub container does not exist", () => { + assertThrows(() => container.where(isProvider("skrill")), MissingChildContainerError); + }); + + it("should throw error when sub container does not have a registered dependency", () => { + assertThrows(() => container.where(isProvider("paypal")).get("payments"), MissingDependencyError); + }); + }); + + describe("when .has() method is used", () => { + const container = new Container(CONTAINER_ID); + + beforeAll(() => { + container.set("payments", new PayPal()); + }); + + it("should return true for registered dependencies", () => { + assertEquals(container.has("payments"), true); + }); + + it("should return false for unregistered dependencies", () => { + assertEquals(container.has("invoices"), false); + }); + }); + + describe("when .set() method is used", () => { + const container = new Container(CONTAINER_ID); + + it("should set new dependency", () => { + assertEquals(container.set("payments", new PayPal()).has("payments"), true); + }); + }); + + describe("when .get() method is used", () => { + const container = new Container(CONTAINER_ID); + + beforeAll(() => { + container.set("logger", Logger); + container.set("payments", new Stripe()); + container.set("invoices", Invoice2Go); + }); + + it("should resolve correct instances", () => { + assertInstanceOf(container.new("logger"), Logger); + assertInstanceOf(container.get("payments"), Stripe); + assertInstanceOf(container.new("invoices", "xyz"), Invoice2Go); + }); + + it("should resolve correct results", async () => { + assertObjectMatch(await container.get("payments").create("xyz", "usd", 100), { + provider: "stripe", + currency: "usd", + amount: 100, + }); + }); + + it("should resolve a transient provider with correct arguments", () => { + assertEquals(container.new("invoices", "xyz").provider, "Invoice2Go"); + }); + }); +}); diff --git a/tests/mocks/providers/invoice-2-go.ts b/tests/mocks/providers/invoice-2-go.ts new file mode 100644 index 0000000..4e958ef --- /dev/null +++ b/tests/mocks/providers/invoice-2-go.ts @@ -0,0 +1,5 @@ +import { Invoices } from "../services/invoices.ts"; + +export class Invoice2Go extends Invoices { + public readonly provider = "Invoice2Go"; +} diff --git a/tests/mocks/providers/paypal.ts b/tests/mocks/providers/paypal.ts new file mode 100644 index 0000000..bf7dd83 --- /dev/null +++ b/tests/mocks/providers/paypal.ts @@ -0,0 +1,14 @@ +import { type Currency, type Payment, Payments } from "../services/payments.ts"; + +export class PayPal extends Payments { + public async create(customerId: string, currency: Currency, amount: number): Promise { + return { + paymentId: "xyz", + customerId, + provider: "paypal", + status: "created", + currency, + amount, + }; + } +} diff --git a/tests/mocks/providers/stripe.ts b/tests/mocks/providers/stripe.ts new file mode 100644 index 0000000..27acb4c --- /dev/null +++ b/tests/mocks/providers/stripe.ts @@ -0,0 +1,14 @@ +import { type Currency, type Payment, Payments } from "../services/payments.ts"; + +export class Stripe extends Payments { + public async create(customerId: string, currency: Currency, amount: number): Promise { + return { + paymentId: "xyz", + customerId, + provider: "stripe", + status: "created", + currency, + amount, + }; + } +} diff --git a/tests/mocks/services/invoices.ts b/tests/mocks/services/invoices.ts new file mode 100644 index 0000000..878ee18 --- /dev/null +++ b/tests/mocks/services/invoices.ts @@ -0,0 +1,5 @@ +export abstract class Invoices { + public abstract readonly provider: string; + + constructor(public readonly paymentId: string) {} +} diff --git a/tests/mocks/services/logger.ts b/tests/mocks/services/logger.ts new file mode 100644 index 0000000..a7816e2 --- /dev/null +++ b/tests/mocks/services/logger.ts @@ -0,0 +1 @@ +export class Logger {} diff --git a/tests/mocks/services/payments.ts b/tests/mocks/services/payments.ts new file mode 100644 index 0000000..a69b8bf --- /dev/null +++ b/tests/mocks/services/payments.ts @@ -0,0 +1,22 @@ +export abstract class Payments { + public abstract create(customerId: string, currency: Currency, amount: number): Promise; +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +export type Payment = { + paymentId: string; + customerId: string; + provider: string; + status: Status; + currency: Currency; + amount: number; +}; + +export type Status = "created" | "processing" | "failed" | "processed"; + +export type Currency = "usd" | "eur" | "jpy";