/cc-gsg-csharp

Getting Started with Camunda Cloud and the C# Client

Primary LanguageC#

Getting Started with Camunda Cloud using C# and ASP .NET Core 3

The Zeebe C# Client is available for .NET Zeebe applications.

Watch a video tutorial on YouTube walking through this Getting Started Guide.

Prerequisites

Scaffolding the project

  • Create a new .NET Core Web API application:
dotnet new webapi -o Cloudstarter
cd Cloudstarter

Video link

dotnet add package zb-client --version 0.16.1

Configure NLog for logging

  • Install NLog packages (we'll use NLog):
dotnet add package NLog 
dotnet add package NLog.Schema 
dotnet add package NLog.Web.AspNetCore 
  • Create a file NLog.config, with the following content:
<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd" 
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      autoReload="true"
>
    <extensions>
        <add assembly="NLog.Web.AspNetCore"/>
    </extensions>
    
    <targets>
        <target name="logconsole" xsi:type="Console" 
                layout="${longdate} | ${level:uppercase=true} | ${logger} | ${message} ${exception:format=tostring}"/>
     </targets>
    
    <rules>
       <logger name="*" minlevel="Trace" writeTo="logconsole" />
     </rules>
</nlog>
  • Edit the file Program.cs to configure NLog:
public class Program
{
    public static async Task Main(string[] args)
    {
        var logger = NLogBuilder.ConfigureNLog("NLog.config").GetCurrentClassLogger();
        try
        {
            logger.Debug("init main");
            await CreateHostBuilder(args).Build().RunAsync();
        }
        catch (Exception exception)
        {
            logger.Error(exception, "Stopped program because of exception");
            throw;
        }
        finally
        {
            NLog.LogManager.Shutdown();
        }
    }

    private static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); })
            .ConfigureLogging(logging =>
            {
                logging.ClearProviders();
                logging.SetMinimumLevel(LogLevel.Trace);
            })
            .UseNLog();  
}

Video link

Create Camunda Cloud cluster

Video link

  • Log in to https://camunda.io.
  • Create a new Zeebe 0.23.3 cluster.
  • When the new cluster appears in the console, create a new set of client credentials.
  • Copy the client Connection Info environment variables block.

Configure connection

Video link

  • Add the dotenv.net package to the project:
dotnet add package dotenv.net.DependencyInjection.Microsoft
  • Edit Startup.cs and add the service in the ConfigureServices method:
public void ConfigureServices(IServiceCollection services)
{
    // ...
    services.AddEnv(builder => {
        builder
            .AddEnvFile("CamundaCloud.env")
            .AddThrowOnError(false)
            .AddEncoding(Encoding.ASCII);
    });
    services.AddEnvReader();
}
  • Create a file in the root of the project CamundaCloud.env, and paste the client connection details into it, removing the export from each line:
ZEEBE_ADDRESS=656a9fc4-c874-49a3-b67b-20c31ae12fa0.zeebe.camunda.io:443
ZEEBE_CLIENT_ID=~2WQlDeV1yFdtePBRQgsrNXaKMs4IwAw
ZEEBE_CLIENT_SECRET=3wFRuCJb4YPcKL4W9Fn7kXlsepSNNJI5h7Mlkqxk2E.coMEtYdA5E58lnkCmoN_0
ZEEBE_AUTHORIZATION_SERVER_URL=https://login.cloud.camunda.io/oauth/token

Note: if you change cluster configuration at a later date, you may need to delete the file ~/zeebe/cloud.token. See this bug report.

  • Add an ItemGroup in CloudStarter.csproj to copy the .env file into the build:
<ItemGroup>
    <None Update="CamundaCloud.env" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
  • Create a file in Services/ZeebeService.cs, with the following content:
namespace Cloudstarter.Services
{
    public interface IZeebeService
    {
        public Task<ITopology> Status();
    }
    public class ZeebeService: IZeebeService
    {
        private readonly IZeebeClient _client;
        private readonly ILogger<ZeebeService> _logger;

        public ZeebeService(IEnvReader envReader, ILogger<ZeebeService> logger)
        {
            _logger = logger;
            var authServer = envReader.GetStringValue("ZEEBE_AUTHORIZATION_SERVER_URL"); 
            var clientId = envReader.GetStringValue("ZEEBE_CLIENT_ID");
            var clientSecret = envReader.GetStringValue("ZEEBE_CLIENT_SECRET");
            var zeebeUrl = envReader.GetStringValue("ZEEBE_ADDRESS");
            char[] port =
            {
                '4', '3', ':'
            };
            var audience = zeebeUrl?.TrimEnd(port);

            _client =
                ZeebeClient.Builder()
                    .UseGatewayAddress(zeebeUrl)
                    .UseTransportEncryption()
                    .UseAccessTokenSupplier(
                        CamundaCloudTokenProvider.Builder()
                            .UseAuthServer(authServer)
                            .UseClientId(clientId)
                            .UseClientSecret(clientSecret)
                            .UseAudience(audience)
                            .Build())
                    .Build();
        }

        public Task<ITopology> Status()
        {
            return _client.TopologyRequest().Send();
        }
    }
}
  • Save the file.

Test Connection with Camunda Cloud

Video link

We will create a controller route at /status that retrieves the status and topology of the cluster.

  • Create a file Controllers/ZeebeController.cs, with the following content:
namespace Cloudstarter.Controllers
{
    public class ZeebeController : Controller
    {
        private readonly IZeebeService _zeebeService;

        public ZeebeController(IZeebeService zeebeService)
        {
            _zeebeService = zeebeService;
        }

        [Route("/status")]
        [HttpGet]
        public async Task<string> Get()
        {
            return (await _zeebeService.Status()).ToString();
        }
    }
}
  • Edit the file Startup.cs, and inject the ZeebeService class into the service container in the ConfigureServices method, like this:
public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<IZeebeService, ZeebeService>();
    services.AddControllers();
}
  • Run the application with the command dotnet run (remember to set the client connection variables in the environment first).

Note: you can use dotnet watch run to automatically restart your application when you change your code.

You will see the topology response from the cluster.

Create a BPMN model

Video link

  • Download and install the Zeebe Modeler.
  • Open Zeebe Modeler and create a new BPMN Diagram.
  • Create a new BPMN diagram.
  • Add a StartEvent, an EndEvent, and a Task.
  • Click on the Task, click on the little spanner/wrench icon, and select "Service Task".
  • Set the Name of the Service Task to Get Time, and the Type to get-time.

It should look like this:

  • Click on the blank canvas of the diagram, and set the Id to test-process, and the Name to "Test Process".
  • Save the diagram to Resources/test-process.bpmn in your project.

Deploy the BPMN model to Camunda Cloud

Video Link

We need to copy the bpmn file into the build, so that it is available to our program at runtime.

  • Edit the Cloudstarter.csproj file, and add the following to the ItemGroup:
<ItemGroup>
    <None Update="Resources\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

Now we create a method in our service to deploy a bpmn model to the cluster.

  • Edit ZeebeService.cs, and add a Deploy method:
public async Task<IDeployResponse> Deploy(string modelFilename)
{
    var filename = Path.Combine(AppDomain.CurrentDomain.BaseDirectory!, "Resources", modelFilename);
    var deployment = await _client.NewDeployCommand().AddResourceFile(filename).Send();
    var res = deployment.Workflows[0];
    _logger.LogInformation("Deployed BPMN Model: " + res?.BpmnProcessId +
                " v." + res?.Version);
    return deployment;
}
  • In the ZeebeService.cs file, update the interface definition:
public interface IZeebeService
{
    public Task<IDeployResponse> Deploy(string modelFilename);
    public Task<ITopology> Status();
}

Now, we call the Deploy method during the initialization of the service at startup. We need to do it here, because the service is not instantiated

  • Edit Startup.cs, and add the following lines to the Configure method:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    var zeebeService = app.ApplicationServices.GetService<IZeebeService>();
    zeebeService.Deploy("test-process.bpmn"); 
    // ...
}

Start a Workflow Instance

Video Link

We will create a controller route at /start that will start a new instance of the workflow.

  • Add fastJSON to the project:
dotnet add package fastJSON
  • Edit Services/ZeebeService.cs and add a StartWorkflowInstance method:
public async Task<String> StartWorkflowInstance(string bpmProcessId)
{
    var instance = await _client.NewCreateWorkflowInstanceCommand()
            .BpmnProcessId(bpmProcessId)
            .LatestVersion()
            .Send();
    var jsonParams = new JSONParameters {ShowReadOnlyProperties = true};
    return JSON.ToJSON(instance, jsonParams);
}
  • Update the service interface definition:
public interface IZeebeService
{
    public Task<IDeployResponse> Deploy(string modelFile);
    public Task<ITopology> Status();
    public Task<String> StartWorkflowInstance(string bpmProcessId);
}
  • Edit Controllers/ZeebeController.cs, and add a REST method to start an instance of the workflow:
// ...
public class ZeebeController : Controller
    // ...

    [Route("/start")]
    [HttpGet]
    public async Task<string> StartWorkflowInstance()
    {
        var instance = await _zeebeService.StartWorkflowInstance("test-process");
        return instance;
    }
}

You will see output similar to the following:

{"$types":{"Zeebe.Client.Impl.Responses.WorkflowInstanceResponse, Client, Version=0.16.1.0, Culture=neutral, PublicKeyToken=null":"1"},"$type":"1","WorkflowKey":2251799813685454,"BpmnProcessId":"test-process","Version":3,"WorkflowInstanceKey":2251799813686273}

A workflow instance has been started. Let's view it in Operate.

View a Workflow Instance in Operate

Video Link

  • Go to your cluster in the Camunda Cloud Console.
  • In the cluster detail view, click on "View Workflow Instances in Camunda Operate".
  • In the "Instances by Workflow" column, click on "Test Process - 1 Instance in 1 Version".
  • Click the Instance Id to open the instance.
  • You will see the token is stopped at the "Get Time" task.

Let's create a task worker to serve the job represented by this task.

Create a Job Worker

Video Link

We will create a worker program that logs out the job metadata, and completes the job with success.

  • Edit the Services/ZeebeService.cs file, and add a _createWorker method to the ZeebeService class:
// ...
private void _createWorker(String jobType, JobHandler handleJob)
{
    _client.NewWorker()
            .JobType(jobType)
            .Handler(handleJob)
            .MaxJobsActive(5)
            .Name(jobType)
            .PollInterval(TimeSpan.FromSeconds(50))
            .PollingTimeout(TimeSpan.FromSeconds(50))
            .Timeout(TimeSpan.FromSeconds(10))
            .Open();
}
  • Now add a CreateGetTimeWorker method, where we supply the task-type for the worker, and a job handler function:
public void CreateGetTimeWorker()
{
    _createWorker("get-time", async (client, job) =>
    {
        _logger.LogInformation("Received job: " + job);
        await client.NewCompleteJobCommand(job.Key).Send();
    });    
}

The worker handler function is async so that it runs on its own thread.

  • Now create a method StartWorkers:
public void StartWorkers()
{
    CreateGetTimeWorker();
}
  • And add it to the IZeebeService interface:
public interface IZeebeService
{
    public Task<IDeployResponse> Deploy(string modelFile);
    public Task<ITopology> Status();
    public Task<string> StartWorkflowInstance(string bpmProcessId);
    public void StartWorkers();
}
  • Now call this method in the Configure method in Startup.cs:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    var zeebeService = app.ApplicationServices.GetService<IZeebeService>();

    zeebeService.Deploy("test-process.bpmn");
    zeebeService.StartWorkers();
    // ...
}
  • Run the program with the command: dotnet run.

You will see output similar to:

2020-07-16 20:34:25.4971 | DEBUG | Zeebe.Client.Impl.Worker.JobWorker | Job worker (get-time) activated 1 of 5 successfully. 
2020-07-16 20:34:25.4971 | INFO | Cloudstarter.Services.ZeebeService | Received job: Key: 2251799813686173, Type: get-time, WorkflowInstanceKey: 2251799813686168, BpmnProcessId: test-process, WorkflowDefinitionVersion: 3, WorkflowKey: 2251799813685454, ElementId: Activity_1ucrvca, ElementInstanceKey: 2251799813686172, Worker: get-time, Retries: 3, Deadline: 07/16/2020 20:34:35, Variables: {}, CustomHeaders: {} 
  • Go back to Operate. You will see that the workflow instance is gone.
  • Click on "Running Instances".
  • In the filter on the left, select "Finished Instances".

You will see the completed workflow instance.

Create and Await the Outcome of a Workflow Instance

We will now create the workflow instance, and get the final outcome in the calling code.

  • Edit the ZeebeService.cs file, and edit the StartWorkflowInstance method, to make it look like this:
// ...
public async Task<String> StartWorkflowInstance(string bpmProcessId)
{
    var instance = await _client.NewCreateWorkflowInstanceCommand()
                .BpmnProcessId(bpmProcessId)
                .LatestVersion()
                .WithResult()
                .Send();
    var jsonParams = new JSONParameters {ShowReadOnlyProperties = true};
    return JSON.ToJSON(instance, jsonParams);
}

You will see output similar to the following:

{"$types":{"Zeebe.Client.Impl.Responses.WorkflowInstanceResultResponse, Client, Version=0.16.1.0, Culture=neutral, PublicKeyToken=null":"1"},"$type":"1","WorkflowKey":2251799813686366,"BpmnProcessId":"test-process","Version":4,"WorkflowInstanceKey":2251799813686409,"Variables":"{}"}

Call a REST Service from the Worker

Video link

We are going to make a REST call in the worker handler, to query a remote API for the current GMT time.

  • Edit the ZeebeService.cs file, and edit the CreateGetTimeWorker method, to make it look like this:
// ...
public void CreateGetTimeWorker()
{
    _createWorker("get-time", async (client, job) =>
    {
        _logger.LogInformation("Received job: " + job);
            using (var httpClient = new HttpClient())
            {
                using (var response = await httpClient.GetAsync("https://json-api.joshwulf.com/time"))
                {
                    string apiResponse = await response.Content.ReadAsStringAsync();
                    
                    await client.NewCompleteJobCommand(job.Key)
                        .Variables("{\"time\":" + apiResponse + "}")
                        .Send();
                }
            }
    });    
}
// ...

You will see output similar to the following:

{"$types":{"Zeebe.Client.Impl.Responses.WorkflowInstanceResultResponse, Client, Version=0.16.1.0, Culture=neutral, PublicKeyToken=null":"1"},"$type":"1","WorkflowKey":2251799813686366,"BpmnProcessId":"test-process","Version":4,"WorkflowInstanceKey":2251799813686463,"Variables":"{\"time\":{\"time\":\"Thu, 16 Jul 2020 10:26:13 GMT\",\"hour\":10,\"minute\":26,\"second\":13,\"day\":4,\"month\":6,\"year\":2020}}"}

Make a Decision

Video link

We will edit the model to add a Conditional Gateway.

  • Open the BPMN model file bpmn/test-process.bpmn in the Zeebe Modeler.
  • Drop a Gateway between the Service Task and the End event.
  • Add two Service Tasks after the Gateway.
  • In one, set the Name to Before noon and the Type to make-greeting.
  • Switch to the Headers tab on that Task, and create a new Key greeting with the Value Good morning.
  • In the second, set the Name to After noon and the Type to make-greeting.
  • Switch to the Headers tab on that Task, and create a new Key greeting with the Value Good afternoon.
  • Click on the arrow connecting the Gateway to the Before noon task.
  • Under Details enter the following in Condition expression:
=time.hour >=0 and time.hour <=11
  • Click on the arrow connecting the Gateway to the After noon task.
  • Click the spanner/wrench icon and select "Default Flow".
  • Connect both Service Tasks to the End Event.

It should look like this:

Create a Worker that acts based on Custom Headers

Video link

We will create a second worker that combines the value of a custom header with the value of a variable in the workflow.

  • Edit the ZeebeService.cs file and create a couple of DTO classes to aid with deserialization of the job:
public class MakeGreetingCustomHeadersDTO
{
    public string greeting { get; set; }
}

public class MakeGreetingVariablesDTO
{
    public string name { get; set; }
}
  • In the same file, create a CreateMakeGreetingWorker method:
 public void CreateMakeGreetingWorker()
{
    _createWorker("make-greeting", async (client, job) =>
    {
        _logger.LogInformation("Make Greeting Received job: " + job);
        var headers = JSON.ToObject<MakeGreetingCustomHeadersDTO>(job.CustomHeaders);
        var variables = JSON.ToObject<MakeGreetingVariablesDTO>(job.Variables);
        string greeting = headers.greeting;
        string name = variables.name;

        await client.NewCompleteJobCommand(job.Key)
            .Variables("{\"say\": \"" + greeting + " " + name + "\"}")
            .Send();
        _logger.LogInformation("Make Greeting Worker completed job");
    });    
}
  • Now call this method in the StartWorkers method of the ZeebeService:
public void StartWorkers()
{
    CreateGetTimeWorker();
    CreateMakeGreetingWorker();
}
  • Edit the startWorkflowInstance method, and pass in a variable name when you create the workflow:
// ...
public async Task<String> StartWorkflowInstance(string bpmProcessId)
{
    var instance = await _client.NewCreateWorkflowInstanceCommand()
        .BpmnProcessId(bpmProcessId)
        .LatestVersion()
        .Variables("{\"name\": \"Josh Wulf\"}")
        .WithResult()
        .Send();
    var jsonParams = new JSONParameters {ShowReadOnlyProperties = true};
    return JSON.ToJSON(instance, jsonParams);
}

You can change the variable name value to your own name (or derive it from the url path or a parameter).

You will see output similar to the following:

{"$types":{"Zeebe.Client.Impl.Responses.WorkflowInstanceResultResponse, Client, Version=0.16.1.0, Culture=neutral, PublicKeyToken=null":"1"},"$type":"1","WorkflowKey":2251799813686683,"BpmnProcessId":"test-process","Version":5,"WorkflowInstanceKey":2251799813687157,"Variables":"{\"say\":\"Good Afternoon Josh Wulf\",\"name\":\"Josh Wulf\",\"time\":{\"time\":\"Thu, 16 Jul 2020 12:45:33 GMT\",\"hour\":12,\"minute\":45,\"second\":33,\"day\":4,\"month\":6,\"year\":2020}}"}

Profit!

Congratulations. You've completed the Getting Started Guide for Camunda Cloud using C# and ASP .NET Core.