diff --git a/README.md b/README.md index 7fe2c420..292e33f8 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ The Dapr community can be found on [Discord](https://discord.com/invite/ptHhX6jc ## Contributing -Please see our [Contributing Overview](https://docs.dapr.io/contributing/sdk-contrib/js-contributing/). +Please see our [Contributing Overview](https://docs.dapr.io/contributing/sdk-contrib/js-contributing/) and [Development Guide](./documentation//development.md) for more information on how to contribute to the Dapr JS SDK. ### Good First Issues diff --git a/daprdocs/content/en/js-sdk-docs/js-actors/_index.md b/daprdocs/content/en/js-sdk-docs/js-actors/_index.md index bb3517ef..d8a24864 100644 --- a/daprdocs/content/en/js-sdk-docs/js-actors/_index.md +++ b/daprdocs/content/en/js-sdk-docs/js-actors/_index.md @@ -136,7 +136,42 @@ console.log(`Registered Actors: ${JSON.stringify(resRegisteredActors)}`); ## Invoking Actor Methods -After Actors are registered, create a Proxy object that implements `ParkingSensorInterface` using the `ActorProxyBuilder`. You can invoke the actor methods by directly calling methods on the Proxy object. Internally, it translates to making a network call to the Actor API and fetches the result back. +After Actors are registered, we can create a Proxy object that uses a implementation stub class (as we require the methods through reflection internally). You can invoke the actor methods by directly calling methods on the Proxy object. Internally, it translates to making a network call to the Actor API and fetches the result back. + +```typescript +export default class ParkingSensorContract { + async carEnter(): Promise { + throw new Error("Not implemented"); + } + + async carLeave(): Promise { + throw new Error("Not implemented"); + } +} +``` + +```typescript +import { ActorId, DaprClient } from "@dapr/dapr"; +import ParkingSensorContract from "./ParkingSensorContract"; + +const daprHost = "127.0.0.1"; +const daprPort = "50000"; + +const client = new DaprClient({ daprHost, daprPort }); + +// Create a new actor builder for the registered actor ParkingSensorContract with interface ParkingSensorContract. It can be used to create multiple actors of a type. +const builder = new ActorProxyBuilder("ParkingSensorContract", ParkingSensorContract, client); + +// Create a new actor instance. +const actor = builder.build(new ActorId("my-actor")); +// Or alternatively, use a random ID +// const actor = builder.build(ActorId.createRandomId()); + +// Invoke the method. +await actor.carEnter(); +``` + +Alternatively, you can also use the existing implementation (if you have access to it): ```typescript import { ActorId, DaprClient } from "@dapr/dapr"; @@ -148,8 +183,8 @@ const daprPort = "50000"; const client = new DaprClient({ daprHost, daprPort }); -// Create a new actor builder. It can be used to create multiple actors of a type. -const builder = new ActorProxyBuilder(ParkingSensorImpl, client); +// Create a new actor builder for the registered actor ParkingSensorImpl with interface ParkingSensorImpl. It can be used to create multiple actors of a type. +const builder = new ActorProxyBuilder("ParkingSensorImpl", ParkingSensorImpl, client); // Create a new actor instance. const actor = builder.build(new ActorId("my-actor")); diff --git a/documentation/development.md b/documentation/development.md index 432df1df..5acaf4f0 100644 --- a/documentation/development.md +++ b/documentation/development.md @@ -20,6 +20,30 @@ The command below runs the build process and will rebuild each time we change a npm run start:dev ``` +## Running Tests + +Tests are written per protocol layer: http or grpc. This is done because Dapr requires endpoints to be registered for for pubsub and bindings, making us having to start up the test, initialize those endpoints and then run. Since Dapr is a sidecar architecture, we thus have to start 2 test suites seperately. It requires the following containers: + +- **EMQX:** Used for Binding Tests + - Credentials: http://localhost:18083 (user: admin, pass: public) +- **MongoDB:** Used for State Query API + +```bash +# Start Container +docker run -d --rm --name emqx -p 1883:1883 -p 8081:8081 -p 8083:8083 -p 8883:8883 -p 8084:8084 -p 18083:18083 emqx/emqx +docker run -d --rm --name mongodb -p 27017:27017 mongo + +# Run Unit Tests +npm run test:unit:main +npm run test:unit:actors + +# Start gRPC tests +npm run test:e2e:grpc + +# Start HTTP tests +npm run test:e2e:http +``` + ## Publishing Package Package Maintenance To publish a new package to [https://www.npmjs.com/package/@dapr/dapr](https://www.npmjs.com/package/@dapr/dapr) we need to do the following building and publishing steps. @@ -45,29 +69,6 @@ For **publishing** the package, we simply cut a new release by: Publishing is automated in the CI/CD pipeline. Each time a version is release (GitHub ref starting with `refs/tags/v`) then the pipeline will deploy the package as described in [build.yml](./.github/workflows/build.yml). -## Running Tests - -Tests are written per protocol layer: http or grpc. This is done because Dapr requires endpoints to be registered for for pubsub and bindings, making us having to start up the test, initialize those endpoints and then run. Since Dapr is a sidecar architecture, we thus have to start 2 test suites seperately. It requires the following containers: - -- **EMQX:** Used for Binding Tests - - Credentials: http://localhost:18083 (user: admin, pass: public) -- **MongoDB:** Used for State Query API - -```bash -# Start Container -docker run -d --rm --name emqx -p 1883:1883 -p 8081:8081 -p 8083:8083 -p 8883:8883 -p 8084:8084 -p 18083:18083 emqx/emqx -docker run -d --rm --name mongodb -p 27017:27017 mongo - -# Run Unit Tests -npm run test:unit:main -npm run test:unit:actors - -# Start gRPC tests -npm run test:e2e:grpc - -# Start HTTP tests -npm run test:e2e:http -``` ## Setup GitHub actions diff --git a/src/actors/client/ActorProxyBuilder.ts b/src/actors/client/ActorProxyBuilder.ts index 7b3ec11f..aa1dc314 100644 --- a/src/actors/client/ActorProxyBuilder.ts +++ b/src/actors/client/ActorProxyBuilder.ts @@ -11,15 +11,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { CommunicationProtocolEnum, DaprClient } from "../.."; -import Class from "../../types/Class"; +import { CommunicationProtocolEnum, DaprClient, DaprClientOptions } from "../.."; import ActorClient from "./ActorClient/ActorClient"; import ActorId from "../ActorId"; -import { DaprClientOptions } from "../../types/DaprClientOptions"; +import Class from "../../types/Class"; export default class ActorProxyBuilder { + // The registered actor name + actorTypeName: string; actorClient: ActorClient; - actorTypeClass: Class; + actorAbstractClass: Class; constructor(actorTypeClass: Class, daprClient: DaprClient); constructor( @@ -29,11 +30,41 @@ export default class ActorProxyBuilder { communicationProtocol: CommunicationProtocolEnum, clientOptions: DaprClientOptions, ); - constructor(actorTypeClass: Class, ...args: any[]) { - this.actorTypeClass = actorTypeClass; + constructor( + actorTypeName: string, + actorTypeClass: Class, + daprClient: DaprClient + ); + constructor( + actorTypeName: string, + actorTypeClass: Class, + host: string, + port: string, + communicationProtocol: CommunicationProtocolEnum, + clientOptions: DaprClientOptions, + ); + constructor(...args: any[]) { + let actorTypeName: string; + let actorTypeClass: Class; + let rest: any[]; + + // Determine if the first argument is a string (actorTypeName) or a class + if (typeof args[0] === "string") { + actorTypeName = args[0]; + actorTypeClass = args[1]; + rest = args.slice(2); + } else { + actorTypeClass = args[0]; + actorTypeName = actorTypeClass.name; + rest = args.slice(1); + } - if (args.length == 1) { - const [daprClient] = args; + this.actorTypeName = actorTypeName; + this.actorAbstractClass = actorTypeClass; + + // Create the actor client based on the provided arguments + if (rest.length == 1) { + const [daprClient] = rest; this.actorClient = new ActorClient( daprClient.options.daprHost, daprClient.options.daprPort, @@ -41,21 +72,33 @@ export default class ActorProxyBuilder { daprClient.options, ); } else { - const [host, port, communicationProtocol, clientOptions] = args; + const [host, port, communicationProtocol, clientOptions] = rest; this.actorClient = new ActorClient(host, port, communicationProtocol, clientOptions); } } - build(actorId: ActorId): T { - const actorTypeClassName = this.actorTypeClass.name; + build(actorId?: ActorId | string): T { + const actorIdParsed = actorId ? (actorId instanceof ActorId ? actorId : new ActorId(actorId)) : ActorId.createRandomId(); const actorClient = this.actorClient; + const actorTypeName = this.actorTypeName; + + // Create an instance of the abstract class to inspect its methods + // This won't be used directly but helps with method discovery + const methodNames = Object.getOwnPropertyNames(this.actorAbstractClass.prototype) + .filter(prop => prop !== 'constructor'); + // Create the handler for the proxy const handler = { - get(_target: any, propKey: any, _receiver: any) { + get: (_target: any, prop: any) => { + // Ensure the property exists on the abstract class prototype + if (!methodNames.includes(prop)) { + throw new Error(`Method ${prop} is not defined in the actor class.`); + } + + // Proxy the method call to the actor client return async function (...args: any) { const body = args.length > 0 ? args : null; - const res = await actorClient.actor.invoke(actorTypeClassName, actorId, propKey, body); - + const res = await actorClient.actor.invoke(actorTypeName, actorIdParsed, prop, body); return res; }; }, @@ -63,10 +106,7 @@ export default class ActorProxyBuilder { // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/Proxy // we implement a handler that will take a method and forward it to the actor client - const proxy = new Proxy(this.actorTypeClass, handler); - - // Return a NOT strongly typed API - // @todo: this should return a strongly typed API as well, but requires reflection. How to do this in typescript? + const proxy = new Proxy(this.actorAbstractClass, handler); return proxy as unknown as T; } } diff --git a/src/implementation/Client/GRPCClient/GRPCClient.ts b/src/implementation/Client/GRPCClient/GRPCClient.ts index a73b51f2..4ca8c22e 100644 --- a/src/implementation/Client/GRPCClient/GRPCClient.ts +++ b/src/implementation/Client/GRPCClient/GRPCClient.ts @@ -23,6 +23,7 @@ import { SDK_VERSION } from "../../../version"; import communicationProtocolEnum from "../../../enum/CommunicationProtocol.enum"; import { GrpcEndpoint } from "../../../network/GrpcEndpoint"; + export default class GRPCClient implements IClient { readonly options: DaprClientOptions; diff --git a/src/implementation/Client/GRPCClient/actor.ts b/src/implementation/Client/GRPCClient/actor.ts index dba04175..83b9e54a 100644 --- a/src/implementation/Client/GRPCClient/actor.ts +++ b/src/implementation/Client/GRPCClient/actor.ts @@ -29,6 +29,7 @@ export default class GRPCClientActor implements IClientActorBuilder { // this means that we can't use T to new up an object (sadly enough) so we have to pass it create(actorTypeClass: Class): T { const builder = new ActorProxyBuilder( + actorTypeClass.name, actorTypeClass, this.client.options.daprHost, this.client.options.daprPort, diff --git a/src/implementation/Client/HTTPClient/actor.ts b/src/implementation/Client/HTTPClient/actor.ts index dc8801d2..457e077f 100644 --- a/src/implementation/Client/HTTPClient/actor.ts +++ b/src/implementation/Client/HTTPClient/actor.ts @@ -29,6 +29,7 @@ export default class HTTPClientActor implements IClientActorBuilder { // this means that we can't use T to new up an object (sadly enough) so we have to pass it create(actorTypeClass: Class): T { const builder = new ActorProxyBuilder( + actorTypeClass.name, actorTypeClass, this.client.options.daprHost, this.client.options.daprPort, diff --git a/test/actor/DemoActorCounterContract.ts b/test/actor/DemoActorCounterContract.ts new file mode 100644 index 00000000..36487d53 --- /dev/null +++ b/test/actor/DemoActorCounterContract.ts @@ -0,0 +1,26 @@ +/* +Copyright 2022 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export default class DemoActorCounterContract { + count(): Promise { + throw new Error("Method not implemented."); + } + + countBy(_amount: number, _multiplier: number): Promise { + throw new Error("Method not implemented."); + } + + getCounter(): Promise { + throw new Error("Method not implemented."); + } +} diff --git a/test/e2e/http/actors.test.ts b/test/e2e/http/actors.test.ts index 9e06ebba..3a033a00 100644 --- a/test/e2e/http/actors.test.ts +++ b/test/e2e/http/actors.test.ts @@ -33,6 +33,7 @@ import DemoActorTimerTtlImpl from "../../actor/DemoActorTimerTtlImpl"; import DemoActorReminderTtlImpl from "../../actor/DemoActorReminderTtlImpl"; import DemoActorDeleteStateImpl from "../../actor/DemoActorDeleteStateImpl"; import DemoActorDeleteStateInterface from "../../actor/DemoActorDeleteStateInterface"; +import DemoActorCounterContract from "../../actor/DemoActorCounterContract"; const serverHost = "127.0.0.1"; const serverPort = "50001"; @@ -144,7 +145,23 @@ describe("http/actors", () => { }); describe("actorProxy", () => { - it("should be able to create an actor object through the proxy", async () => { + it("should be able to create an actor object through the proxy (when we have the implementation)", async () => { + const builder = new ActorProxyBuilder("DemoActorCounterImpl", DemoActorCounterImpl, client); + const actor = builder.build(ActorId.createRandomId()); + + const c1 = await actor.getCounter(); + expect(c1).toEqual(0); + + await actor.countBy(1, 1); + const c2 = await actor.getCounter(); + expect(c2).toEqual(1); + + await actor.countBy(1, 10); + const c3 = await actor.getCounter(); + expect(c3).toEqual(11); + }); + + it("should be able to create an actor object through the proxy and the deprecated way of working (when we have the implementation)", async () => { const builder = new ActorProxyBuilder(DemoActorCounterImpl, client); const actor = builder.build(ActorId.createRandomId()); @@ -159,11 +176,27 @@ describe("http/actors", () => { const c3 = await actor.getCounter(); expect(c3).toEqual(11); }); + + it("should be able to create an actor object through the proxy (without requiring the implementation)", async () => { + const builder = new ActorProxyBuilder("DemoActorCounterImpl", DemoActorCounterContract, client); + const actor = builder.build(ActorId.createRandomId()); + + const c1 = await actor.getCounter(); + expect(c1).toEqual(0); + + await actor.countBy(1, 1); + const c2 = await actor.getCounter(); + expect(c2).toEqual(1); + + await actor.countBy(1, 10); + const c3 = await actor.getCounter(); + expect(c3).toEqual(11); + }); }); describe("invokeNonExistentMethod", () => { it("should not fail if invoked non-existing method on actor", async () => { - const builder = new ActorProxyBuilder(DemoActorCounterImpl, client); + const builder = new ActorProxyBuilder("DemoActorCounterImpl", DemoActorCounterImpl, client); const actorId = ActorId.createRandomId(); builder.build(actorId); @@ -181,7 +214,7 @@ describe("http/actors", () => { describe("deleteActorState", () => { it("should be able to delete actor state", async () => { - const builder = new ActorProxyBuilder(DemoActorDeleteStateImpl, client); + const builder = new ActorProxyBuilder("DemoActorDeleteStateImpl", DemoActorDeleteStateImpl, client); const actor = builder.build(ActorId.createRandomId()); await actor.init(); @@ -195,6 +228,7 @@ describe("http/actors", () => { expect(deletedRes).toEqual(false); }); }); + describe("invoke", () => { it("should register actors correctly", async () => { const actors = await server.actor.getRegisteredActors(); @@ -215,21 +249,21 @@ describe("http/actors", () => { }); it("should be able to invoke an actor through a text message", async () => { - const builder = new ActorProxyBuilder(DemoActorSayImpl, client); + const builder = new ActorProxyBuilder("DemoActorSayImpl", DemoActorSayImpl, client); const actor = builder.build(ActorId.createRandomId()); const res = await actor.sayString("Hello World"); expect(res).toEqual(`Actor said: "Hello World"`); }); it("should be able to invoke an actor through an object message", async () => { - const builder = new ActorProxyBuilder(DemoActorSayImpl, client); + const builder = new ActorProxyBuilder("DemoActorSayImpl", DemoActorSayImpl, client); const actor = builder.build(ActorId.createRandomId()); const res = await actor.sayObject({ hello: "world" }); expect(JSON.stringify(res)).toEqual(`{"said":{"hello":"world"}}`); }); it("should be able to invoke an actor through multiple parameters", async () => { - const builder = new ActorProxyBuilder(DemoActorSayImpl, client); + const builder = new ActorProxyBuilder("DemoActorSayImpl", DemoActorSayImpl, client); const actor = builder.build(ActorId.createRandomId()); const res = await actor.sayMulti(123, "123", { hello: "world 123" }, [1, 2, 3]); expect(JSON.stringify(res)).toEqual( @@ -248,7 +282,7 @@ describe("http/actors", () => { describe("timers", () => { it("should fire a timer correctly (expected execution time > 5s)", async () => { - const builder = new ActorProxyBuilder(DemoActorTimerImpl, client); + const builder = new ActorProxyBuilder("DemoActorTimerImpl", DemoActorTimerImpl, client); const actor = builder.build(ActorId.createRandomId()); // Activate our actor @@ -285,7 +319,7 @@ describe("http/actors", () => { }, 10000); it("should apply the ttl when it is set (expected execution time > 5s)", async () => { - const builder = new ActorProxyBuilder(DemoActorTimerTtlImpl, client); + const builder = new ActorProxyBuilder("DemoActorTimerTtlImpl", DemoActorTimerTtlImpl, client); const actor = builder.build(ActorId.createRandomId()); // Activate our actor @@ -322,7 +356,7 @@ describe("http/actors", () => { }, 10000); it("should only fire once when period is not set to a timer", async () => { - const builder = new ActorProxyBuilder(DemoActorTimerOnceImpl, client); + const builder = new ActorProxyBuilder("DemoActorTimerOnceImpl", DemoActorTimerOnceImpl, client); const actor = builder.build(ActorId.createRandomId()); // Activate our actor @@ -350,7 +384,7 @@ describe("http/actors", () => { describe("reminders", () => { it("should be able to unregister a reminder", async () => { - const builder = new ActorProxyBuilder(DemoActorReminderImpl, client); + const builder = new ActorProxyBuilder("DemoActorReminderImpl", DemoActorReminderImpl, client); const actor = builder.build(ActorId.createRandomId()); // Activate our actor @@ -379,7 +413,7 @@ describe("http/actors", () => { }); it("should fire a reminder but with a warning if it's not implemented correctly", async () => { - const builder = new ActorProxyBuilder(DemoActorReminder2Impl, client); + const builder = new ActorProxyBuilder("DemoActorReminder2Impl", DemoActorReminder2Impl, client); const actorId = ActorId.createRandomId(); const actor = builder.build(actorId); @@ -407,7 +441,7 @@ describe("http/actors", () => { }); it("should apply the ttl when it is set to a reminder", async () => { - const builder = new ActorProxyBuilder(DemoActorReminderTtlImpl, client); + const builder = new ActorProxyBuilder("DemoActorReminderTtlImpl", DemoActorReminderTtlImpl, client); const actor = builder.build(ActorId.createRandomId()); // Activate our actor @@ -435,7 +469,7 @@ describe("http/actors", () => { }); it("should only fire once when period is not set to a reminder", async () => { - const builder = new ActorProxyBuilder(DemoActorReminderOnceImpl, client); + const builder = new ActorProxyBuilder("DemoActorReminderOnceImpl", DemoActorReminderOnceImpl, client); const actor = builder.build(ActorId.createRandomId()); // Activate our actor