Kickstart a Node module

The Akka Serverless code generation tool for JavaScript helps you to get started quickly with a project that you can build using npm or Yarn. The tool generates the recommended project structure (including package.json), a Value Entity, unit tests, and a README.md that explains what was created and how to work with the newly created service.

You can modify the .proto and source files, and the tools will generate code stubs for the elements you changed or added. The generated project also contains scripts for running the service locally and for packaging into a container and deploying on Akka Serverless

The term "project" used in connection with code generation is a development project that contains your service implementation. This differs from an Akka Serverless project, which is where you deploy one or more packaged services.

Prerequisites

Before running the code generation tools, make sure that you have the following:

1. Prepare the development project

The code generation tools create a new development project named my-entity, that contains build configuration and the gRPC Protobuf specifications for a Value Entity.

  1. To create the initial codebase, from a command window enter the following:

    npm
    npx @lightbend/create-akkasls-entity my-entity
    yarn
    yarn create @lightbend/akkasls-entity my-entity
  2. Change directories to the newly created folder:

    cd my-entity
  3. Prepare the build unit by installing all the required dependencies and tools:

    npm
    npm install
    yarn
    yarn

Take a look at the initial structure. The project contains the following directories:

  • lib - typescript files that allow IDEs to provide better highlighting and validation.

  • proto - files containing domain and api Protobuf definitions for a Counter Value Entity.

  • test - testkit file

To understand how the Protobuf definitions relate to the implementation files, see The development process and Understanding the programming model.

2. Build and generate code

During the build process, the tools generates source files and unit test templates for a Value Entity. You can change or add to the .proto and source files. The next build will generate classes and tests for your additions if they do not exist, but will not overwrite existing classes or tests.

Build the project to generate source code:

npm
npm run build
yarn
yarn build

You should now see the following source files in your project:

  • src/counter.js - the implementation of the counter Value Entity.

  • src/index.js - a convenience file that starts the Akka Serverless proxy for running locally

  • test/counter.test.js - a test for the counter Value Entity.

The lib/generated directory contains files used by the tools. Any changes made to files in this folder are overwritten on the next build.

2.1. Unit and integration tests

Akka Serverless codegen creates a unit test stub for each class within counter.test.js. Use these stubs as a starting point to test the logic in your implementation. You can use any of the common Node.js testing tools, such as Mocha or Jest.

/*
 * Copyright 2021 Lightbend Inc.
 *
 * 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.
 */

import { MockValueEntity } from "./testkit.js";
import { expect } from "chai";
import counter from "../src/counter.js";

const CounterState = counter.lookupType("com.example.domain.CounterState");

describe("CounterService", () => {
  const entityId = "entityId";

  describe("Increase", () => {

    it("should increase the value with no prior state", () => {
      const entity = new MockValueEntity(counter, entityId);
      const result = entity.handleCommand("Increase", { entityId: entityId, value: 42 });

      expect(result).to.deep.equal({});
      expect(entity.error).to.be.undefined;
      expect(entity.state).to.deep.equal(CounterState.create({ value: 42 }));
    });

    it("should increase the value with some prior state", () => {
      const entity = new MockValueEntity(counter, entityId);
      entity.state = CounterState.create({ value: 13 });
      const result = entity.handleCommand("Increase", { entityId: entityId, value: 42 });

      expect(result).to.deep.equal({});
      expect(entity.error).to.be.undefined;
      expect(entity.state).to.deep.equal(CounterState.create({ value: 13 + 42 }));
    });

    it("should fail on negative values", () => {
      const entity = new MockValueEntity(counter, entityId);
      const result = entity.handleCommand("Increase", { entityId: entityId, value: -2 });

      expect(result).to.deep.equal({});
      expect(entity.error).to.be.equal(`Increase requires a positive value. It was [-2].`);
    });
  });

  describe("Decrease", () => {
    it("should decrease the value with no prior state.", () => {
      const entity = new MockValueEntity(counter, entityId);
      const result = entity.handleCommand("Decrease", { entityId: entityId, value: 42 });

      expect(result).to.deep.equal({});
      expect(entity.error).to.be.undefined;
      expect(entity.state).to.deep.equal(CounterState.create({ value: -42 }));
    });
  });

  describe("Reset", () => {
    it("should reset the entity value to 0", () => {
      const entity = new MockValueEntity(counter, entityId);
      entity.state = CounterState.create({ value: 13 });
      const result = entity.handleCommand("Reset", { entityId: entityId });

      expect(result).to.deep.equal({});
      expect(entity.error).to.be.undefined;
      expect(entity.state).to.deep.equal(CounterState.create({ value: 0 }));
    });
  });

  describe("GetCurrentCounter", () => {
    it("should return the current state", () => {
      const entity = new MockValueEntity(counter, entityId);
      entity.state = CounterState.create({ value: 13 });
      const result = entity.handleCommand("GetCurrentCounter", { entityId: entityId });

      expect(result).to.deep.equal({ value: 13 });
      expect(entity.error).to.be.undefined;
      expect(entity.state).to.deep.equal(CounterState.create({ value: 13 }));
    });
  });
});

The entity used by counter.test.js is contained within testkit.js.

/*
 * Copyright 2021 Lightbend Inc.
 *
 * 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.
 */

/**
 * Mocks the behaviour of a single Akka Serverless EventSourcedEntity.
 *
 * Handles any commands and events, internally tracking the state and maintaining an event log.
 *
 * NOTE: Entity IDs are not handled, so all commands are assumed to refer to a single entity.
 */
export class MockEventSourcedEntity {
  events = [];
  state;
  error;
  grpcService;

  constructor(entity, entityId) {
    this.entity = entity;
    this.entityId = entityId;
    this.state = entity.initial(entityId);
    this.grpcService = entity.serviceName
      .split(".")
      .reduce((obj, part) => obj[part], entity.grpc).service;
  }

  /**
   * Handle the provided command, and return the result. Any emitted events are also handled.
   *
   * @param {string} commandName the command method name (as per the entity proto definition)
   * @param {object} command the request body
   * @param {MockEventSourcedCommandContext} ctx override the context object for this handler for advanced behaviour
   * @returns the result of the command
   */
  handleCommand(
    commandName,
    command,
    ctx = new MockEventSourcedCommandContext()
  ) {
    const behaviors = this.entity.behavior(this.state);
    const handler = behaviors.commandHandlers[commandName];
    const grpcMethod = this.grpcService[commandName];

    const request = grpcMethod.requestDeserialize(
      grpcMethod.requestSerialize(command)
    );

    const result = handler(request, this.state, ctx);
    ctx.events.forEach((event) => this.handleEvent(event));
    this.error = ctx.error;

    return grpcMethod.responseDeserialize(grpcMethod.responseSerialize(result));
  }

  /**
   * Handle the provied event, and add it to the event log.
   * @param {object} event the event payload
   */
  handleEvent(event) {
    const behaviors = this.entity.behavior(this.state);
    const handler =
      behaviors.eventHandlers[event.type || event.constructor.name];

    this.state = handler(event, this.state);
    this.events.push(event);
  }
}

/**
 * Mocks the behaviour of a single Akka Serverless Value entity.
 *
 * Handles any commands, internally maintaining the state.
 *
 * NOTE: Entity IDs are not handled, so all commands are assumed to refer to a single entity.
 */
export class MockValueEntity {
  state;
  error;
  grpcService;

  constructor(entity, entityId) {
    this.entity = entity;
    this.entityId = entityId;
    this.state = entity.initial(entityId);
    this.grpcService = entity.serviceName
      .split(".")
      .reduce((obj, part) => obj[part], entity.grpc).service;
  }

  /**
   * Handle the provided command, and return the result. Any emitted events are also handled.
   *
   * @param {string} commandName the command method name (as per the entity proto definition)
   * @param {object} command the request body
   * @param {MockValueEntityCommandContext} ctx override the context object for this handler for advanced behaviour
   * @returns the result of the command
   */
  handleCommand(
    commandName,
    command,
    ctx = new MockValueEntityCommandContext()
  ) {
    const handler = this.entity.commandHandlers[commandName];
    const grpcMethod = this.grpcService[commandName];

    const request = grpcMethod.requestDeserialize(
      grpcMethod.requestSerialize(command)
    );

    const result = handler(request, this.state, ctx);
    if (ctx.delete) {
      this.state = this.entity.initial(this.entityId);
    } else if (ctx.updatedState) {
      this.state = ctx.updatedState;
    }
    this.error = ctx.error;

    return grpcMethod.responseDeserialize(grpcMethod.responseSerialize(result));
  }
}

/**
 * Generic mock CommandContext for any Akka Serverless entity
 * @type { import("../lib/akkaserverless").CommandContext }
 */
export class MockCommandContext {
  effects = [];
  thenForward = () => {};
  error;

  /**
   * Set the `thenForward` callback for this context.
   * This allows tests handling both failure and success cases for forwarded commands.
   * @param  handler the thenForward callback to set
   */
  onForward(handler) {
    this.thenForward = handler;
  }

  fail(error) {
    this.error = error;
  }

  effect(method, message, synchronous, metadata) {
    this.effects.push({
      method,
      message,
      synchronous,
      metadata,
    });
  }
}

/**
 * Mocks the behaviour of the command context object within Akka Serverless.
 *
 * By default, calls to [AkkaServerlessTestKitEntity~handleCommand] will
 * construct their own instance of this class, however for making assertions on
 * forwarding or emmitted effects you may provide your own.
 *
 * @type { import("../lib/akkaserverless").EventSourcedCommandContext<unknown> }
 */
export class MockEventSourcedCommandContext extends MockCommandContext {
  events = [];

  emit(event) {
    this.events.push(event);
  }
}

/**
 * Mocks the behaviour of the command context object within Akka Serverless.
 *
 * By default, calls to [AkkaServerlessTestKitEntity~handleCommand] will
 * construct their own instance of this class, however for making assertions on
 * forwarding or emmitted effects you may provide your own.
 *
 * @type { import("../lib/akkaserverless").ValueEntityCommandContext<unknown> }
 */
export class MockValueEntityCommandContext extends MockCommandContext {
  updatedState = undefined;
  delete = false;

  updateState(state) {
    this.updatedState = state;
  }

  deleteState() {
    this.delete = true;
  }
}

3. Run locally

You can run your services locally and test them manually with HTTP or gRPC requests. To run locally, you need to initiate the Akka Serverless proxy. The docker-compose file contains the configuration required to run the proxy.

  1. To start the proxy, run the following command from the top-level project directory:

    macOS, Windows
    docker compose up
    Linux
    On Linux this requires Docker 20.10 or later (https://github.com/moby/moby/pull/40007), or for a USER_FUNCTION_HOST environment variable to be set manually.
    docker compose -f docker-compose.yml -f docker-compose.linux.yml up
  2. Start the application locally with the following command:

    npm
    npm run start
    yarn
    yarn start
For more details see Run a service locally.

4. Package service

The project contains configuration files for packaging the service into a Docker image, which can then be deployed to Akka Serverless. The Docker image name can be changed in the package.json config.dockerImage string value. Update this configuration to publish your image to your Docker repository.

The recommended version is Node 14 and the image is based on the official Node Docker Image. Choose a different image in the Dockerfile placed in the root folder of the development project.

npm
npm run package
yarn
yarn package

5. Deploy to Akka Serverless

The build tool script conveniently packages and publishes the Docker image prior to deployment.

To deploy your service:

  1. Update the dockerImage property in the project’s package.json file.

    Refer to Configuring registries for more information on how to make your docker image available to Akka Serverless.

  2. In your command window, set your Akka Serverless project to be the current project:

    akkasls config set project <project-name>
  3. Run the deployment scripts:

    npm
    npm run deploy
    yarn
    yarn deploy

Summary

The code generation tools created a project and supporting resources to kickstart your development. Modify the .proto and implementations or replace them with your own and take advantage of the generated resources. To learn more about the generated Counter Service, see Implementing Value Entities in JavaScript.