├── global.json ├── src └── Camunda.Worker │ ├── Variables │ ├── VariableBase.cs │ ├── NullVariable.cs │ ├── LongVariable.cs │ ├── ShortVariable.cs │ ├── BooleanVariable.cs │ ├── BytesVariable.cs │ ├── DoubleVariable.cs │ ├── IntegerVariable.cs │ ├── StringVariable.cs │ ├── XmlVariable.cs │ ├── UnknownVariable.cs │ └── JsonVariable.cs │ ├── AssemblyInfo.cs │ ├── ExternalTaskDelegate.cs │ ├── Client │ ├── ErrorResponse.cs │ ├── ExtendLockRequest.cs │ ├── HttpClientExtensions.cs │ ├── ReportFailureRequest.cs │ ├── CompleteRequest.cs │ ├── ClientException.cs │ ├── BpmnErrorRequest.cs │ ├── IExternalTaskClient.cs │ ├── Serialization │ │ ├── XmlVariableJsonConverter.cs │ │ ├── JsonVariableJsonConverter.cs │ │ └── VariableJsonConverter.cs │ ├── ServiceCollectionExtensions.cs │ ├── FetchAndLockRequest.cs │ └── ExternalTaskClient.cs │ ├── Constants.cs │ ├── WorkerServiceFactory.cs │ ├── Routing │ ├── IEndpointResolver.cs │ ├── ExternalTaskRouter.cs │ └── TopicBasedEndpointResolver.cs │ ├── NoneResult.cs │ ├── Endpoints │ ├── IEndpointsCollection.cs │ ├── EndpointsCollection.cs │ ├── Endpoint.cs │ └── EndpointMetadata.cs │ ├── Execution │ ├── ICamundaWorker.cs │ ├── IFetchAndLockRequestProvider.cs │ ├── IExternalTaskProcessingService.cs │ ├── FetchAndLockOptions.cs │ ├── ExternalTaskContext.cs │ ├── WorkerEvents.cs │ ├── WorkerHostedService.cs │ ├── ExternalTaskProcessingService.cs │ ├── HandlerInvoker.cs │ ├── FetchAndLockRequestProvider.cs │ └── DefaultCamundaWorker.cs │ ├── ObjectExtensions.cs │ ├── IExternalTaskHandler.cs │ ├── IExternalTaskContext.cs │ ├── IPipelineBuilder.cs │ ├── CamundaWorkerException.cs │ ├── IExecutionResult.cs │ ├── HandlerVariablesAttribute.cs │ ├── HandlerTopicsAttribute.cs │ ├── ICamundaWorkerBuilder.cs │ ├── PipelineBuilder.cs │ ├── Guard.cs │ ├── FailureResult.cs │ ├── ExternalTaskVariablesExtensions.cs │ ├── WorkerIdString.cs │ ├── CompleteResult.cs │ ├── Log.cs │ ├── Camunda.Worker.csproj │ ├── ServiceCollectionExtensions.cs │ ├── BpmnErrorResult.cs │ ├── CamundaWorkerBuilderExtensions.cs │ ├── ExternalTask.cs │ └── CamundaWorkerBuilder.cs ├── test └── Camunda.Worker.Tests │ ├── Client │ ├── Serialization │ │ ├── __snapshots__ │ │ │ ├── VariablesSerializationTests.ShouldDeserialize_Bool1.snap │ │ │ ├── VariablesSerializationTests.ShouldDeserialize_Bool2.snap │ │ │ ├── VariablesSerializationTests.ShouldDeserialize_Double1.snap │ │ │ ├── VariablesSerializationTests.ShouldDeserialize_Double2.snap │ │ │ ├── VariablesSerializationTests.ShouldDeserialize_Short1.snap │ │ │ ├── VariablesSerializationTests.ShouldDeserialize_Short2.snap │ │ │ ├── VariablesSerializationTests.ShouldSerialize_Bool1.snap │ │ │ ├── VariablesSerializationTests.ShouldSerialize_Bool2.snap │ │ │ ├── VariablesSerializationTests.ShouldSerialize_Double2.snap │ │ │ ├── VariablesSerializationTests.ShouldSerialize_Short1.snap │ │ │ ├── VariablesSerializationTests.ShouldSerialize_Short2.snap │ │ │ ├── VariablesSerializationTests.ShouldDeserialize_Bytes.snap │ │ │ ├── VariablesSerializationTests.ShouldDeserialize_Int1.snap │ │ │ ├── VariablesSerializationTests.ShouldDeserialize_Int2.snap │ │ │ ├── VariablesSerializationTests.ShouldDeserialize_String.snap │ │ │ ├── VariablesSerializationTests.ShouldSerialize_Bytes.snap │ │ │ ├── VariablesSerializationTests.ShouldSerialize_Double1.snap │ │ │ ├── VariablesSerializationTests.ShouldSerialize_Int1.snap │ │ │ ├── VariablesSerializationTests.ShouldSerialize_Int2.snap │ │ │ ├── VariablesSerializationTests.ShouldSerialize_Long2.snap │ │ │ ├── VariablesSerializationTests.ShouldDeserialize_Double3.snap │ │ │ ├── VariablesSerializationTests.ShouldDeserialize_Long1.snap │ │ │ ├── VariablesSerializationTests.ShouldDeserialize_Long2.snap │ │ │ ├── VariablesSerializationTests.ShouldSerialize_Double3.snap │ │ │ ├── VariablesSerializationTests.ShouldSerialize_Long1.snap │ │ │ ├── VariablesSerializationTests.ShouldSerialize_String.snap │ │ │ ├── VariablesSerializationTests.ShouldDeserialize_Xml.snap │ │ │ ├── VariablesSerializationTests.ShouldSerialize_Xml.snap │ │ │ ├── VariablesSerializationTests.ShouldSerialize_Json.snap │ │ │ └── VariablesSerializationTests.ShouldDeserialize_Json.snap │ │ └── VariablesSerializationTests.cs │ └── ExternalTaskClientTest.cs │ ├── NoneResultTest.cs │ ├── Execution │ ├── WorkerHostedServiceTest.cs │ ├── FetchAndLockRequestProviderTests.cs │ ├── HandlerInvokerTest.cs │ └── DefaultCamundaWorkerTest.cs │ ├── Endpoints │ └── EndpointsCollectionTests.cs │ ├── Camunda.Worker.Tests.csproj │ ├── FailureResultTest.cs │ ├── Routing │ ├── TopicBasedEndpointResolverTest.cs │ └── ExternalTaskRouterTest.cs │ ├── ServiceCollectionExtensionsTest.cs │ ├── PipelineBuilderTest.cs │ ├── BpmnErrorResultTest.cs │ ├── CompleteResultTest.cs │ ├── CamundaWorkerBuilderTest.cs │ └── CamundaWorkerBuilderExtensionsTest.cs ├── samples └── SampleCamundaWorker │ ├── appsettings.Development.json │ ├── appsettings.json │ ├── SampleCamundaWorker.csproj │ ├── Properties │ └── launchSettings.json │ ├── Program.cs │ ├── docker-compose.yml │ ├── Handlers │ ├── SayHelloGuestHandler.cs │ └── SayHelloHandler.cs │ ├── Startup.cs │ └── say-hello.bpmn ├── nuget.config ├── .editorconfig ├── coverlet.runsettings ├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── LICENSE ├── README.md ├── .gitignore └── Camunda.Worker.sln /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "9.0.100", 4 | "rollForward": "latestMinor" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Variables/VariableBase.cs: -------------------------------------------------------------------------------- 1 | namespace Camunda.Worker.Variables; 2 | 3 | public abstract record VariableBase; 4 | -------------------------------------------------------------------------------- /src/Camunda.Worker/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | [assembly: InternalsVisibleTo("Camunda.Worker.Tests")] 4 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Variables/NullVariable.cs: -------------------------------------------------------------------------------- 1 | namespace Camunda.Worker.Variables; 2 | 3 | public sealed record NullVariable : VariableBase; 4 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldDeserialize_Bool1.snap: -------------------------------------------------------------------------------- 1 | { 2 | "Value": true 3 | } 4 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldDeserialize_Bool2.snap: -------------------------------------------------------------------------------- 1 | { 2 | "Value": false 3 | } 4 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldDeserialize_Double1.snap: -------------------------------------------------------------------------------- 1 | { 2 | "Value": 5E-324 3 | } 4 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldDeserialize_Double2.snap: -------------------------------------------------------------------------------- 1 | { 2 | "Value": 0.0 3 | } 4 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldDeserialize_Short1.snap: -------------------------------------------------------------------------------- 1 | { 2 | "Value": -32768 3 | } 4 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldDeserialize_Short2.snap: -------------------------------------------------------------------------------- 1 | { 2 | "Value": 32767 3 | } 4 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldSerialize_Bool1.snap: -------------------------------------------------------------------------------- 1 | {"value":true,"type":"Boolean"} 2 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldSerialize_Bool2.snap: -------------------------------------------------------------------------------- 1 | {"value":false,"type":"Boolean"} 2 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldSerialize_Double2.snap: -------------------------------------------------------------------------------- 1 | {"value":0,"type":"Double"} 2 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldSerialize_Short1.snap: -------------------------------------------------------------------------------- 1 | {"value":-32768,"type":"Short"} 2 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldSerialize_Short2.snap: -------------------------------------------------------------------------------- 1 | {"value":32767,"type":"Short"} 2 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Variables/LongVariable.cs: -------------------------------------------------------------------------------- 1 | namespace Camunda.Worker.Variables; 2 | 3 | public sealed record LongVariable(long Value) : VariableBase; 4 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Variables/ShortVariable.cs: -------------------------------------------------------------------------------- 1 | namespace Camunda.Worker.Variables; 2 | 3 | public sealed record ShortVariable(short Value) : VariableBase; 4 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldDeserialize_Bytes.snap: -------------------------------------------------------------------------------- 1 | { 2 | "Value": "aGVsbG8=" 3 | } 4 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldDeserialize_Int1.snap: -------------------------------------------------------------------------------- 1 | { 2 | "Value": -2147483648 3 | } 4 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldDeserialize_Int2.snap: -------------------------------------------------------------------------------- 1 | { 2 | "Value": 2147483647 3 | } 4 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldDeserialize_String.snap: -------------------------------------------------------------------------------- 1 | { 2 | "Value": "\"Hello!\"" 3 | } 4 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldSerialize_Bytes.snap: -------------------------------------------------------------------------------- 1 | {"value":"aGVsbG8=","type":"Bytes"} 2 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldSerialize_Double1.snap: -------------------------------------------------------------------------------- 1 | {"value":5E-324,"type":"Double"} 2 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldSerialize_Int1.snap: -------------------------------------------------------------------------------- 1 | {"value":-2147483648,"type":"Integer"} 2 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldSerialize_Int2.snap: -------------------------------------------------------------------------------- 1 | {"value":2147483647,"type":"Integer"} 2 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Variables/BooleanVariable.cs: -------------------------------------------------------------------------------- 1 | namespace Camunda.Worker.Variables; 2 | 3 | public sealed record BooleanVariable(bool Value) : VariableBase; 4 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Variables/BytesVariable.cs: -------------------------------------------------------------------------------- 1 | namespace Camunda.Worker.Variables; 2 | 3 | public sealed record BytesVariable(byte[] Value) : VariableBase; 4 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Variables/DoubleVariable.cs: -------------------------------------------------------------------------------- 1 | namespace Camunda.Worker.Variables; 2 | 3 | public sealed record DoubleVariable(double Value) : VariableBase; 4 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Variables/IntegerVariable.cs: -------------------------------------------------------------------------------- 1 | namespace Camunda.Worker.Variables; 2 | 3 | public sealed record IntegerVariable(int Value) : VariableBase; 4 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Variables/StringVariable.cs: -------------------------------------------------------------------------------- 1 | namespace Camunda.Worker.Variables; 2 | 3 | public sealed record StringVariable(string Value) : VariableBase; 4 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldSerialize_Long2.snap: -------------------------------------------------------------------------------- 1 | {"value":9223372036854775807,"type":"Long"} 2 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldDeserialize_Double3.snap: -------------------------------------------------------------------------------- 1 | { 2 | "Value": 3.141592653589793 3 | } 4 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldDeserialize_Long1.snap: -------------------------------------------------------------------------------- 1 | { 2 | "Value": -9223372036854775808 3 | } 4 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldDeserialize_Long2.snap: -------------------------------------------------------------------------------- 1 | { 2 | "Value": 9223372036854775807 3 | } 4 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldSerialize_Double3.snap: -------------------------------------------------------------------------------- 1 | {"value":3.141592653589793,"type":"Double"} 2 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldSerialize_Long1.snap: -------------------------------------------------------------------------------- 1 | {"value":-9223372036854775808,"type":"Long"} 2 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldSerialize_String.snap: -------------------------------------------------------------------------------- 1 | {"value":"\u0022Hello!\u0022","type":"String"} 2 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldDeserialize_Xml.snap: -------------------------------------------------------------------------------- 1 | { 2 | "Value": { 3 | "username": "John" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldSerialize_Xml.snap: -------------------------------------------------------------------------------- 1 | {"value":"\u003Cusername\u003EJohn\u003C/username\u003E","type":"Xml"} 2 | -------------------------------------------------------------------------------- /src/Camunda.Worker/ExternalTaskDelegate.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | namespace Camunda.Worker; 4 | 5 | public delegate Task ExternalTaskDelegate(IExternalTaskContext context); 6 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Variables/XmlVariable.cs: -------------------------------------------------------------------------------- 1 | using System.Xml.Linq; 2 | 3 | namespace Camunda.Worker.Variables; 4 | 5 | public sealed record XmlVariable(XDocument Value) : VariableBase; 6 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Variables/UnknownVariable.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Nodes; 2 | 3 | namespace Camunda.Worker.Variables; 4 | 5 | public sealed record UnknownVariable(string Type, JsonNode? Value) : VariableBase; 6 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldSerialize_Json.snap: -------------------------------------------------------------------------------- 1 | {"value":"{\u0022Username\u0022:\u0022John\u0022,\u0022Roles\u0022:[\u0022Admin\u0022]}","type":"Json"} 2 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Client/ErrorResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Camunda.Worker.Client; 2 | 3 | public class ErrorResponse 4 | { 5 | public string Type { get; set; } = ""; 6 | public string Message { get; set; } = ""; 7 | } 8 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Constants.cs: -------------------------------------------------------------------------------- 1 | namespace Camunda.Worker; 2 | 3 | internal static class Constants 4 | { 5 | internal const int MinimumLockDuration = 5_000; 6 | internal const int MinimumParallelExecutors = 1; 7 | } 8 | -------------------------------------------------------------------------------- /src/Camunda.Worker/WorkerServiceFactory.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Camunda.Worker; 4 | 5 | public delegate TService WorkerServiceFactory(WorkerIdString workerId, IServiceProvider serviceProvider); 6 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Routing/IEndpointResolver.cs: -------------------------------------------------------------------------------- 1 | using Camunda.Worker.Endpoints; 2 | 3 | namespace Camunda.Worker.Routing; 4 | 5 | public interface IEndpointResolver 6 | { 7 | Endpoint? Resolve(ExternalTask externalTask); 8 | } 9 | -------------------------------------------------------------------------------- /src/Camunda.Worker/NoneResult.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | namespace Camunda.Worker; 4 | 5 | public class NoneResult : IExecutionResult 6 | { 7 | public Task ExecuteResultAsync(IExternalTaskContext context) => Task.CompletedTask; 8 | } 9 | -------------------------------------------------------------------------------- /samples/SampleCamundaWorker/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Endpoints/IEndpointsCollection.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace Camunda.Worker.Endpoints; 4 | 5 | public interface IEndpointsCollection 6 | { 7 | IEnumerable GetEndpoints(WorkerIdString workerId); 8 | } 9 | -------------------------------------------------------------------------------- /samples/SampleCamundaWorker/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | }, 9 | "AllowedHosts": "*" 10 | } 11 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Execution/ICamundaWorker.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | 4 | namespace Camunda.Worker.Execution; 5 | 6 | public interface ICamundaWorker 7 | { 8 | Task RunAsync(CancellationToken cancellationToken); 9 | } 10 | -------------------------------------------------------------------------------- /src/Camunda.Worker/ObjectExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Camunda.Worker; 4 | 5 | internal static class ObjectExtensions 6 | { 7 | internal static T Also(this T self, Action action) 8 | { 9 | action(self); 10 | return self; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/__snapshots__/VariablesSerializationTests.ShouldDeserialize_Json.snap: -------------------------------------------------------------------------------- 1 | { 2 | "Value": { 3 | "Username": { 4 | "Options": null 5 | }, 6 | "Roles": [ 7 | { 8 | "Options": null 9 | } 10 | ] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Camunda.Worker/IExternalTaskHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | 4 | namespace Camunda.Worker; 5 | 6 | public interface IExternalTaskHandler 7 | { 8 | Task HandleAsync(ExternalTask externalTask, CancellationToken cancellationToken); 9 | } 10 | -------------------------------------------------------------------------------- /nuget.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /samples/SampleCamundaWorker/SampleCamundaWorker.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Execution/IFetchAndLockRequestProvider.cs: -------------------------------------------------------------------------------- 1 | using Camunda.Worker.Client; 2 | 3 | namespace Camunda.Worker.Execution; 4 | 5 | public interface IFetchAndLockRequestProvider 6 | { 7 | /// 8 | /// This method is called in the worker before each "fetch and lock" operation 9 | /// 10 | FetchAndLockRequest GetRequest(); 11 | } 12 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Client/ExtendLockRequest.cs: -------------------------------------------------------------------------------- 1 | namespace Camunda.Worker.Client; 2 | 3 | public class ExtendLockRequest 4 | { 5 | public ExtendLockRequest(string workerId, int newDuration) 6 | { 7 | WorkerId = Guard.NotNull(workerId, nameof(workerId)); 8 | NewDuration = newDuration; 9 | } 10 | 11 | public string WorkerId { get; } 12 | public int NewDuration { get; } 13 | } 14 | -------------------------------------------------------------------------------- /src/Camunda.Worker/IExternalTaskContext.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using Camunda.Worker.Client; 4 | 5 | namespace Camunda.Worker; 6 | 7 | public interface IExternalTaskContext 8 | { 9 | ExternalTask Task { get; } 10 | 11 | IExternalTaskClient Client { get; } 12 | 13 | IServiceProvider ServiceProvider { get; } 14 | 15 | CancellationToken ProcessingAborted { get; } 16 | } 17 | -------------------------------------------------------------------------------- /src/Camunda.Worker/IPipelineBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Camunda.Worker; 4 | 5 | public interface IPipelineBuilder 6 | { 7 | IServiceProvider ApplicationServices { get; } 8 | 9 | WorkerIdString WorkerId { get; } 10 | 11 | IPipelineBuilder Use(Func middleware); 12 | 13 | ExternalTaskDelegate Build(ExternalTaskDelegate lastDelegate); 14 | } 15 | -------------------------------------------------------------------------------- /samples/SampleCamundaWorker/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "SampleCamundaWorker": { 5 | "commandName": "Project", 6 | "launchBrowser": false, 7 | "applicationUrl": "http://localhost:5000", 8 | "environmentVariables": { 9 | "ASPNETCORE_ENVIRONMENT": "Development" 10 | } 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://EditorConfig.org 2 | 3 | # This file is the top-most EditorConfig file 4 | root = true 5 | 6 | # All Files 7 | [*] 8 | charset = utf-8 9 | end_of_line = lf 10 | indent_style = space 11 | indent_size = 4 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | 15 | [*.sln] 16 | indent_style = tab 17 | end_of_line = crlf 18 | insert_final_newline = false 19 | 20 | [*.{csproj,json,yml}] 21 | indent_size = 2 22 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Execution/IExternalTaskProcessingService.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | using Camunda.Worker.Client; 4 | 5 | namespace Camunda.Worker.Execution; 6 | 7 | internal interface IExternalTaskProcessingService 8 | { 9 | Task ProcessAsync( 10 | ExternalTask externalTask, 11 | IExternalTaskClient externalTaskClient, 12 | CancellationToken cancellationToken 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/Camunda.Worker/CamundaWorkerException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | 4 | namespace Camunda.Worker; 5 | 6 | [ExcludeFromCodeCoverage] 7 | public class CamundaWorkerException : Exception 8 | { 9 | public CamundaWorkerException(string? message) : base(message) 10 | { 11 | } 12 | 13 | public CamundaWorkerException(string? message, Exception? innerException) : base(message, innerException) 14 | { 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Client/HttpClientExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http; 2 | using System.Net.Http.Headers; 3 | using System.Net.Mime; 4 | 5 | namespace Camunda.Worker.Client; 6 | 7 | internal static class HttpClientExtensions 8 | { 9 | internal static bool IsJson(this HttpContentHeaders headers) => 10 | headers.ContentType?.MediaType == MediaTypeNames.Application.Json; 11 | 12 | internal static bool IsJson(this HttpResponseMessage message) => 13 | message.Content.Headers.IsJson(); 14 | } 15 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Client/ReportFailureRequest.cs: -------------------------------------------------------------------------------- 1 | namespace Camunda.Worker.Client; 2 | 3 | public class ReportFailureRequest 4 | { 5 | public ReportFailureRequest(string workerId) 6 | { 7 | WorkerId = Guard.NotNull(workerId, nameof(workerId)); 8 | } 9 | 10 | public string WorkerId { get; } 11 | public string? ErrorMessage { get; set; } 12 | public string? ErrorDetails { get; set; } 13 | 14 | public int? Retries { get; set; } 15 | 16 | public int? RetryTimeout { get; set; } 17 | } 18 | -------------------------------------------------------------------------------- /samples/SampleCamundaWorker/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Hosting; 2 | using Microsoft.Extensions.Hosting; 3 | 4 | namespace SampleCamundaWorker; 5 | 6 | public class Program 7 | { 8 | public static void Main(string[] args) 9 | { 10 | CreateHostBuilder(args).Build().Run(); 11 | } 12 | 13 | public static IHostBuilder CreateHostBuilder(string[] args) => 14 | Host.CreateDefaultBuilder(args) 15 | .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); }); 16 | } 17 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Client/CompleteRequest.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Camunda.Worker.Variables; 3 | 4 | namespace Camunda.Worker.Client; 5 | 6 | public class CompleteRequest 7 | { 8 | public CompleteRequest(string workerId) 9 | { 10 | WorkerId = Guard.NotNull(workerId, nameof(workerId)); 11 | } 12 | 13 | public string WorkerId { get; } 14 | 15 | public Dictionary? Variables { get; set; } 16 | 17 | public Dictionary? LocalVariables { get; set; } 18 | } 19 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/NoneResultTest.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Moq; 3 | using Xunit; 4 | 5 | namespace Camunda.Worker; 6 | 7 | public class NoneResultTest 8 | { 9 | private readonly Mock _contextMock = new(); 10 | 11 | [Fact] 12 | public async Task TestExecuteResultAsync() 13 | { 14 | // Arrange 15 | var result = new NoneResult(); 16 | 17 | // Act 18 | await result.ExecuteResultAsync(_contextMock.Object); 19 | 20 | // Assert 21 | _contextMock.VerifyNoOtherCalls(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Endpoints/EndpointsCollection.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | 4 | namespace Camunda.Worker.Endpoints; 5 | 6 | internal sealed class EndpointsCollection : IEndpointsCollection 7 | { 8 | private readonly List _endpoints; 9 | 10 | public EndpointsCollection(IEnumerable endpoints) 11 | { 12 | _endpoints = endpoints.ToList(); 13 | } 14 | 15 | public IEnumerable GetEndpoints(WorkerIdString workerId) 16 | { 17 | return _endpoints.Where(e => e.WorkerId == workerId); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Endpoints/Endpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Camunda.Worker.Endpoints; 2 | 3 | public sealed class Endpoint 4 | { 5 | public Endpoint(ExternalTaskDelegate handlerDelegate, EndpointMetadata metadata, WorkerIdString workerId) 6 | { 7 | WorkerId = workerId; 8 | HandlerDelegate = Guard.NotNull(handlerDelegate, nameof(handlerDelegate)); 9 | Metadata = Guard.NotNull(metadata, nameof(metadata)); 10 | } 11 | 12 | public ExternalTaskDelegate HandlerDelegate { get; } 13 | 14 | public EndpointMetadata Metadata { get; } 15 | 16 | public WorkerIdString WorkerId { get; } 17 | } 18 | -------------------------------------------------------------------------------- /src/Camunda.Worker/IExecutionResult.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | namespace Camunda.Worker; 4 | 5 | /// 6 | /// Defines a contract that represents the result of external task's handler 7 | /// 8 | public interface IExecutionResult 9 | { 10 | /// 11 | /// Executes the result operation of the external task's handler asynchronously 12 | /// 13 | /// The context in which the result is executed 14 | /// A task that represents the asynchronous execute operation. 15 | Task ExecuteResultAsync(IExternalTaskContext context); 16 | } 17 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Execution/FetchAndLockOptions.cs: -------------------------------------------------------------------------------- 1 | namespace Camunda.Worker.Execution; 2 | 3 | public class FetchAndLockOptions 4 | { 5 | private int _maxTasks = 1; 6 | private int _asyncResponseTimeout = 10_000; 7 | 8 | public int MaxTasks 9 | { 10 | get => _maxTasks; 11 | set => _maxTasks = Guard.GreaterThanOrEqual(value, 1, nameof(MaxTasks)); 12 | } 13 | 14 | public int AsyncResponseTimeout 15 | { 16 | get => _asyncResponseTimeout; 17 | set => _asyncResponseTimeout = Guard.GreaterThanOrEqual(value, 0, nameof(AsyncResponseTimeout)); 18 | } 19 | 20 | public bool UsePriority { get; set; } = true; 21 | } 22 | -------------------------------------------------------------------------------- /samples/SampleCamundaWorker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | camunda-db: 5 | image: postgres:12 6 | ports: 7 | - '5432:5432' 8 | environment: 9 | POSTGRES_DB: process-engine 10 | POSTGRES_USER: camunda 11 | POSTGRES_PASSWORD: camunda 12 | 13 | camunda: 14 | image: camunda/camunda-bpm-platform:7.14.0 15 | depends_on: 16 | - camunda-db 17 | ports: 18 | - '8080:8080' 19 | environment: 20 | DB_DRIVER: 'org.postgresql.Driver' 21 | DB_URL: 'jdbc:postgresql://camunda-db:5432/process-engine' 22 | DB_USERNAME: camunda 23 | DB_PASSWORD: camunda 24 | WAIT_FOR: 'camunda-db:5432' 25 | -------------------------------------------------------------------------------- /src/Camunda.Worker/HandlerVariablesAttribute.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace Camunda.Worker; 5 | 6 | [AttributeUsage(AttributeTargets.Class)] 7 | public class HandlerVariablesAttribute : Attribute 8 | { 9 | public HandlerVariablesAttribute(params string[] variables) 10 | { 11 | Variables = variables; 12 | } 13 | 14 | public IReadOnlyList Variables { get; } 15 | 16 | public bool LocalVariables { get; set; } 17 | 18 | /// 19 | ///Setting this to true will retrieve all the process variables from Camunda without the need of knowing their names 20 | /// 21 | public bool AllVariables { get; set; } 22 | } 23 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Client/ClientException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net; 3 | 4 | namespace Camunda.Worker.Client; 5 | 6 | public class ClientException : Exception 7 | { 8 | private readonly ErrorResponse _errorResponse; 9 | 10 | public ClientException(ErrorResponse errorResponse, HttpStatusCode statusCode) 11 | { 12 | _errorResponse = errorResponse; 13 | StatusCode = statusCode; 14 | } 15 | 16 | public HttpStatusCode StatusCode { get; } 17 | public string ErrorType => _errorResponse.Type; 18 | public string ErrorMessage => _errorResponse.Message; 19 | 20 | public override string Message => $"Camunda error of type \"{ErrorType}\" with message \"{ErrorMessage}\""; 21 | } 22 | -------------------------------------------------------------------------------- /coverlet.runsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | opencover 8 | [Camunda.Worker]* 9 | GeneratedCodeAttribute,CompilerGeneratedAttribute 10 | true 11 | true 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Variables/JsonVariable.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json; 3 | using System.Text.Json.Nodes; 4 | 5 | namespace Camunda.Worker.Variables; 6 | 7 | public sealed record JsonVariable(JsonNode Value) : VariableBase 8 | { 9 | public static JsonVariable Create(T value, JsonSerializerOptions? options = null) 10 | { 11 | var jsonNode = JsonSerializer.SerializeToNode(value, options) 12 | ?? throw new ArgumentException("Given object could not be serialized to json", nameof(value)); 13 | 14 | return new JsonVariable(jsonNode); 15 | } 16 | 17 | public T? Parse(JsonSerializerOptions? options = null) 18 | { 19 | return Value.Deserialize(options); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Client/BpmnErrorRequest.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Camunda.Worker.Variables; 3 | 4 | namespace Camunda.Worker.Client; 5 | 6 | public class BpmnErrorRequest 7 | { 8 | public BpmnErrorRequest(string workerId, string errorCode, string errorMessage) 9 | { 10 | WorkerId = Guard.NotNull(workerId, nameof(workerId)); 11 | ErrorCode = Guard.NotEmptyAndNotNull(errorCode, nameof(errorCode)); 12 | ErrorMessage = Guard.NotEmptyAndNotNull(errorMessage, nameof(errorMessage)); 13 | } 14 | 15 | public string WorkerId { get; } 16 | 17 | public string ErrorCode { get; } 18 | 19 | public string ErrorMessage { get; } 20 | 21 | public Dictionary? Variables { get; set; } 22 | } 23 | -------------------------------------------------------------------------------- /samples/SampleCamundaWorker/Handlers/SayHelloGuestHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using Camunda.Worker; 5 | using Camunda.Worker.Variables; 6 | 7 | namespace SampleCamundaWorker.Handlers; 8 | 9 | [HandlerTopics("sayHelloGuest")] 10 | public class SayHelloGuestHandler : IExternalTaskHandler 11 | { 12 | public Task HandleAsync(ExternalTask externalTask, CancellationToken cancellationToken) 13 | { 14 | return Task.FromResult(new CompleteResult 15 | { 16 | Variables = new Dictionary 17 | { 18 | ["MESSAGE"] = new StringVariable("Hello, Guest!") 19 | } 20 | }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Camunda.Worker/HandlerTopicsAttribute.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | 5 | namespace Camunda.Worker; 6 | 7 | [AttributeUsage(AttributeTargets.Class)] 8 | public class HandlerTopicsAttribute : Attribute 9 | { 10 | private int _lockDuration = Constants.MinimumLockDuration; 11 | 12 | public HandlerTopicsAttribute(params string[] topicNames) 13 | { 14 | Guard.NotNull(topicNames, nameof(topicNames)); 15 | 16 | TopicNames = topicNames.ToList(); 17 | } 18 | 19 | public IReadOnlyList TopicNames { get; } 20 | 21 | public int LockDuration 22 | { 23 | get => _lockDuration; 24 | set => _lockDuration = Guard.GreaterThanOrEqual(value, Constants.MinimumLockDuration, nameof(LockDuration)); 25 | } 26 | 27 | public bool IncludeExtensionProperties { get; set; } 28 | } 29 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Routing/ExternalTaskRouter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.Extensions.DependencyInjection; 4 | 5 | namespace Camunda.Worker.Routing; 6 | 7 | internal static class ExternalTaskRouter 8 | { 9 | internal static async Task RouteAsync(IExternalTaskContext context) 10 | { 11 | Guard.NotNull(context, nameof(context)); 12 | var provider = context.ServiceProvider; 13 | 14 | var endpointResolver = provider.GetRequiredKeyedService(context.Task.WorkerId); 15 | var endpoint = endpointResolver.Resolve(context.Task); 16 | 17 | if (endpoint is null) 18 | { 19 | throw new CamundaWorkerException($"Endpoint for external task {context.Task.Id} could not be resolved"); 20 | } 21 | 22 | await endpoint.HandlerDelegate(context); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Camunda.Worker/ICamundaWorkerBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Camunda.Worker.Endpoints; 3 | using Camunda.Worker.Execution; 4 | using Camunda.Worker.Routing; 5 | using Microsoft.Extensions.DependencyInjection; 6 | 7 | namespace Camunda.Worker; 8 | 9 | public interface ICamundaWorkerBuilder 10 | { 11 | IServiceCollection Services { get; } 12 | 13 | WorkerIdString WorkerId { get; } 14 | 15 | ICamundaWorkerBuilder AddEndpointResolver(WorkerServiceFactory factory); 16 | 17 | ICamundaWorkerBuilder AddFetchAndLockRequestProvider(WorkerServiceFactory factory); 18 | 19 | ICamundaWorkerBuilder AddHandler(ExternalTaskDelegate handler, EndpointMetadata endpointMetadata); 20 | 21 | ICamundaWorkerBuilder ConfigurePipeline(Action configureAction); 22 | 23 | ICamundaWorkerBuilder ConfigureEvents(Action configureAction); 24 | } 25 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Execution/ExternalTaskContext.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using Camunda.Worker.Client; 4 | 5 | namespace Camunda.Worker.Execution; 6 | 7 | public sealed class ExternalTaskContext : IExternalTaskContext 8 | { 9 | public ExternalTaskContext( 10 | ExternalTask task, 11 | IExternalTaskClient client, 12 | IServiceProvider provider, 13 | CancellationToken processingAborted = default 14 | ) 15 | { 16 | Task = Guard.NotNull(task, nameof(task)); 17 | Client = Guard.NotNull(client, nameof(client)); 18 | ServiceProvider = Guard.NotNull(provider, nameof(provider)); 19 | ProcessingAborted = processingAborted; 20 | } 21 | 22 | public ExternalTask Task { get; } 23 | 24 | public IExternalTaskClient Client { get; } 25 | 26 | public IServiceProvider ServiceProvider { get; } 27 | 28 | public CancellationToken ProcessingAborted { get; } 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: [ '*' ] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | env: 11 | DOTNET_NOLOGO: 1 12 | DOTNET_CLI_TELEMETRY_OPTOUT: 1 13 | DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Setup .NET 17 | uses: actions/setup-dotnet@v1 18 | with: 19 | dotnet-version: 9.0.x 20 | - name: Get the version 21 | id: get_version 22 | run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} 23 | - name: Create the package 24 | run: dotnet pack -p:PackageVersion=${{ steps.get_version.outputs.VERSION }} -c Release ./src/Camunda.Worker/Camunda.Worker.csproj 25 | - name: Publish the package 26 | run: dotnet nuget push ./src/Camunda.Worker/bin/Release/*.nupkg -s https://api.nuget.org/v3/index.json -k $NUGET_AUTH_TOKEN 27 | env: 28 | NUGET_AUTH_TOKEN: ${{secrets.NUGET_TOKEN}} 29 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | env: 13 | DOTNET_NOLOGO: 1 14 | DOTNET_CLI_TELEMETRY_OPTOUT: 1 15 | DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Setup .NET 8.0 19 | uses: actions/setup-dotnet@v1 20 | with: 21 | dotnet-version: 8.0.x 22 | - name: Setup .NET 9.0 23 | uses: actions/setup-dotnet@v1 24 | with: 25 | dotnet-version: 9.0.x 26 | - name: Unit tests 27 | run: dotnet test ./test/Camunda.Worker.Tests/Camunda.Worker.Tests.csproj --collect:"XPlat Code Coverage" --settings ./coverlet.runsettings -r ./coverage 28 | - name: Upload coverage to Codecov 29 | uses: codecov/codecov-action@v2 30 | with: 31 | token: ${{ secrets.CODECOV_TOKEN }} 32 | directory: ./coverage 33 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Client/IExternalTaskClient.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | 5 | namespace Camunda.Worker.Client; 6 | 7 | public interface IExternalTaskClient 8 | { 9 | Task> FetchAndLockAsync( 10 | FetchAndLockRequest request, 11 | CancellationToken cancellationToken = default 12 | ); 13 | 14 | Task CompleteAsync( 15 | string taskId, CompleteRequest request, 16 | CancellationToken cancellationToken = default 17 | ); 18 | 19 | Task ReportFailureAsync( 20 | string taskId, ReportFailureRequest request, 21 | CancellationToken cancellationToken = default 22 | ); 23 | 24 | Task ReportBpmnErrorAsync( 25 | string taskId, BpmnErrorRequest request, 26 | CancellationToken cancellationToken = default 27 | ); 28 | 29 | Task ExtendLockAsync( 30 | string taskId, ExtendLockRequest request, 31 | CancellationToken cancellationToken = default 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Routing/TopicBasedEndpointResolver.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using Camunda.Worker.Endpoints; 4 | 5 | namespace Camunda.Worker.Routing; 6 | 7 | public class TopicBasedEndpointResolver : IEndpointResolver 8 | { 9 | private readonly IReadOnlyDictionary _endpoints; 10 | 11 | public TopicBasedEndpointResolver(WorkerIdString workerId, IEndpointsCollection endpointsCollection) 12 | { 13 | _endpoints = endpointsCollection.GetEndpoints(workerId) 14 | .SelectMany(endpoint => endpoint.Metadata.TopicNames 15 | .Select(topicName => new 16 | { 17 | TopicName = topicName, 18 | Endpoint = endpoint 19 | }) 20 | ) 21 | .ToDictionary(pair => pair.TopicName, pair => pair.Endpoint); 22 | } 23 | 24 | public Endpoint? Resolve(ExternalTask externalTask) 25 | { 26 | Guard.NotNull(externalTask, nameof(externalTask)); 27 | return _endpoints.GetValueOrDefault(externalTask.TopicName); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Camunda.Worker/PipelineBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace Camunda.Worker; 5 | 6 | public class PipelineBuilder : IPipelineBuilder 7 | { 8 | private readonly List> _middlewareList = new(); 9 | 10 | public PipelineBuilder(IServiceProvider serviceProvider, WorkerIdString workerId) 11 | { 12 | ApplicationServices = serviceProvider; 13 | WorkerId = workerId; 14 | } 15 | 16 | public IServiceProvider ApplicationServices { get; } 17 | 18 | public WorkerIdString WorkerId { get; } 19 | 20 | public IPipelineBuilder Use(Func middleware) 21 | { 22 | _middlewareList.Add(middleware); 23 | return this; 24 | } 25 | 26 | public ExternalTaskDelegate Build(ExternalTaskDelegate lastDelegate) 27 | { 28 | var result = lastDelegate; 29 | 30 | for (var i = _middlewareList.Count - 1; i >= 0 ; i--) 31 | { 32 | result = _middlewareList[i](result); 33 | } 34 | 35 | return result; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Alexey Malinin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Execution/WorkerEvents.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | 6 | namespace Camunda.Worker.Execution; 7 | 8 | [ExcludeFromCodeCoverage] 9 | public class WorkerEvents 10 | { 11 | public Func OnBeforeFetchAndLock { get; set; } = 12 | DefaultOnBeforeFetchAndLock; 13 | 14 | public Func OnFailedFetchAndLock { get; set; } = 15 | DefaultOnFailedFetchAndLock; 16 | 17 | public Func OnAfterProcessingAllTasks { get; set; } = 18 | DefaultOnAfterProcessingAllTasks; 19 | 20 | private static Task DefaultOnBeforeFetchAndLock(IServiceProvider provider, CancellationToken ct) => 21 | Task.CompletedTask; 22 | 23 | private static Task DefaultOnFailedFetchAndLock(IServiceProvider provider, CancellationToken ct) => 24 | Task.Delay(10_000, ct); 25 | 26 | private static Task DefaultOnAfterProcessingAllTasks(IServiceProvider provider, CancellationToken ct) => 27 | Task.CompletedTask; 28 | } 29 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Guard.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | 4 | namespace Camunda.Worker; 5 | 6 | internal static class Guard 7 | { 8 | [ExcludeFromCodeCoverage] 9 | internal static T NotNull(T parameterValue, string parameterName) where T : class 10 | { 11 | if (parameterValue == null) 12 | { 13 | throw new ArgumentNullException(parameterName); 14 | } 15 | 16 | return parameterValue; 17 | } 18 | 19 | [ExcludeFromCodeCoverage] 20 | internal static int GreaterThanOrEqual(int value, int minValue, string parameterName) 21 | { 22 | if (value < minValue) 23 | { 24 | throw new ArgumentException($"Must be greater than or equal to {minValue}", parameterName); 25 | } 26 | 27 | return value; 28 | } 29 | 30 | [ExcludeFromCodeCoverage] 31 | internal static string NotEmptyAndNotNull(string value, string parameterName) 32 | { 33 | if (string.IsNullOrEmpty(value)) 34 | { 35 | throw new ArgumentException($"Mustn't be null or empty string", parameterName); 36 | } 37 | 38 | return value; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Execution/WorkerHostedService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using Microsoft.Extensions.Hosting; 7 | 8 | namespace Camunda.Worker.Execution; 9 | 10 | internal sealed class WorkerHostedService : BackgroundService 11 | { 12 | private readonly IServiceProvider _serviceProvider; 13 | private readonly WorkerIdString _workerId; 14 | private readonly int _numberOfWorkers; 15 | 16 | public WorkerHostedService(IServiceProvider serviceProvider, WorkerIdString workerId, int numberOfWorkers) 17 | { 18 | _serviceProvider = serviceProvider; 19 | _workerId = workerId; 20 | _numberOfWorkers = numberOfWorkers; 21 | } 22 | 23 | protected override Task ExecuteAsync(CancellationToken stoppingToken) 24 | { 25 | var activeTasks = Enumerable.Range(0, _numberOfWorkers) 26 | .Select(_ => _serviceProvider.GetRequiredKeyedService(_workerId.Value)) 27 | .Select(worker => worker.RunAsync(stoppingToken)) 28 | .ToList(); 29 | return Task.WhenAll(activeTasks); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Client/Serialization/XmlVariableJsonConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json; 3 | using System.Text.Json.Serialization; 4 | using System.Xml.Linq; 5 | using Camunda.Worker.Variables; 6 | 7 | namespace Camunda.Worker.Client.Serialization; 8 | 9 | public class XmlVariableJsonConverter : JsonConverter 10 | { 11 | public override XmlVariable? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 12 | { 13 | using var jsonDocument = JsonDocument.ParseValue(ref reader); 14 | var rootElement = jsonDocument.RootElement; 15 | var serializedXmlValue = rootElement.GetProperty("value").GetString() 16 | ?? throw new JsonException(); 17 | 18 | var deserializedValue = XDocument.Parse(serializedXmlValue); 19 | 20 | return new XmlVariable(deserializedValue); 21 | } 22 | 23 | public override void Write(Utf8JsonWriter writer, XmlVariable value, JsonSerializerOptions options) 24 | { 25 | writer.WriteStartObject(); 26 | writer.WriteString("value", value.Value.ToString(SaveOptions.DisableFormatting)); 27 | writer.WriteEndObject(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Client/ServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.Http; 3 | using System.Text.Json; 4 | using Microsoft.Extensions.DependencyInjection; 5 | 6 | namespace Camunda.Worker.Client; 7 | 8 | public static class ServiceCollectionExtensions 9 | { 10 | public static IHttpClientBuilder AddExternalTaskClient(this IServiceCollection services) 11 | { 12 | return services.AddHttpClient( 13 | httpClient => new ExternalTaskClient(httpClient)); 14 | } 15 | 16 | public static IHttpClientBuilder AddExternalTaskClient(this IServiceCollection services, Action configureClient) 17 | { 18 | return AddExternalTaskClient(services).ConfigureHttpClient(configureClient); 19 | } 20 | 21 | public static IHttpClientBuilder AddExternalTaskClient(this IServiceCollection services, Action configureClient, Action configureJsonOptions) 22 | { 23 | return services.AddHttpClient( 24 | httpClient => new ExternalTaskClient(httpClient, configureJsonOptions)).ConfigureHttpClient(configureClient); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Camunda.Worker/FailureResult.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Camunda.Worker.Client; 4 | 5 | namespace Camunda.Worker; 6 | 7 | public sealed class FailureResult : IExecutionResult 8 | { 9 | public string ErrorMessage { get; } 10 | public string? ErrorDetails { get; } 11 | public int? Retries { get; set; } 12 | public int? RetryTimeout { get; set; } 13 | 14 | public FailureResult(Exception ex) : this(ex.Message, ex.StackTrace) 15 | { 16 | } 17 | 18 | public FailureResult(string errorMessage, string? errorDetails = null) 19 | { 20 | ErrorMessage = Guard.NotNull(errorMessage, nameof(errorMessage)); 21 | ErrorDetails = errorDetails; 22 | } 23 | 24 | public async Task ExecuteResultAsync(IExternalTaskContext context) 25 | { 26 | var externalTask = context.Task; 27 | var client = context.Client; 28 | 29 | await client.ReportFailureAsync(externalTask.Id, new ReportFailureRequest(externalTask.WorkerId) 30 | { 31 | ErrorMessage = ErrorMessage, 32 | ErrorDetails = ErrorDetails, 33 | Retries = Retries, 34 | RetryTimeout = RetryTimeout 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Client/Serialization/JsonVariableJsonConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json; 3 | using System.Text.Json.Nodes; 4 | using System.Text.Json.Serialization; 5 | using Camunda.Worker.Variables; 6 | 7 | namespace Camunda.Worker.Client.Serialization; 8 | 9 | public class JsonVariableJsonConverter : JsonConverter 10 | { 11 | public override JsonVariable? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 12 | { 13 | using var jsonDocument = JsonDocument.ParseValue(ref reader); 14 | var rootElement = jsonDocument.RootElement; 15 | var serializedJsonValue = rootElement.GetProperty("value").GetString() 16 | ?? throw new JsonException(); 17 | 18 | var deserializedValue = JsonNode.Parse(serializedJsonValue) ?? throw new JsonException(); 19 | 20 | return new JsonVariable(deserializedValue); 21 | } 22 | 23 | public override void Write(Utf8JsonWriter writer, JsonVariable value, JsonSerializerOptions options) 24 | { 25 | writer.WriteStartObject(); 26 | writer.WriteString("value", value.Value.ToJsonString(options)); 27 | writer.WriteEndObject(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Camunda.Worker/ExternalTaskVariablesExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using Camunda.Worker.Variables; 3 | 4 | namespace Camunda.Worker; 5 | 6 | public static class ExternalTaskVariablesExtensions 7 | { 8 | public static bool TryGetVariable( 9 | this ExternalTask externalTask, 10 | string variableName, 11 | [NotNullWhen(true)] out T? variable 12 | ) where T : VariableBase 13 | { 14 | if (externalTask.Variables.TryGetValue(variableName, out var value)) 15 | { 16 | if (value is T typedVariable) 17 | { 18 | variable = typedVariable; 19 | return true; 20 | } 21 | } 22 | 23 | variable = null; 24 | return false; 25 | } 26 | 27 | public static T? GetVariableOrDefault( 28 | this ExternalTask externalTask, 29 | string variableName 30 | ) where T : VariableBase 31 | { 32 | if (externalTask.Variables.TryGetValue(variableName, out var value)) 33 | { 34 | if (value is T typedVariable) 35 | { 36 | return typedVariable; 37 | } 38 | } 39 | 40 | return null; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Execution/WorkerHostedServiceTest.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Moq; 5 | using Xunit; 6 | 7 | namespace Camunda.Worker.Execution; 8 | 9 | public class WorkerHostedServiceTest 10 | { 11 | private readonly Mock _workerMock = new(); 12 | 13 | [Theory] 14 | [InlineData(1)] 15 | [InlineData(4)] 16 | public async Task TestRunWorkerOnStart(int numberOfWorkers) 17 | { 18 | var workerId = new WorkerIdString("test-worker"); 19 | 20 | await using var serivceProvider = new ServiceCollection() 21 | .AddKeyedTransient(workerId.Value, (_, _) => _workerMock.Object) 22 | .BuildServiceProvider(); 23 | 24 | _workerMock.Setup(w => w.RunAsync(It.IsAny())) 25 | .Returns(Task.CompletedTask); 26 | 27 | using (var workerHostedService = new WorkerHostedService(serivceProvider, workerId, numberOfWorkers)) 28 | { 29 | await workerHostedService.StartAsync(CancellationToken.None); 30 | } 31 | 32 | _workerMock.Verify(w => w.RunAsync(It.IsAny()), Times.Exactly(numberOfWorkers)); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Endpoints/EndpointsCollectionTests.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Bogus; 3 | using Xunit; 4 | 5 | namespace Camunda.Worker.Endpoints; 6 | 7 | public class EndpointsCollectionTests 8 | { 9 | [Fact] 10 | public void GetEndpoints_Should_ReturnOnlyEndpointsWithRequestedWorkerId() 11 | { 12 | // Arrange 13 | var workerId1 = new WorkerIdString(new Faker().Lorem.Word()); 14 | var workerId2 = new WorkerIdString(new Faker().Lorem.Word()); 15 | 16 | static Task FakeHandlerDelegate(IExternalTaskContext context) => Task.CompletedTask; 17 | 18 | var worker1Endpoint = new Endpoint(FakeHandlerDelegate, new EndpointMetadata(["topic1"]), workerId1); 19 | var worker2Endpoint = new Endpoint(FakeHandlerDelegate, new EndpointMetadata(["test2"], 10_000) 20 | { 21 | Variables = ["X"], 22 | LocalVariables = true 23 | }, workerId2); 24 | 25 | var sut = new EndpointsCollection(new [] { worker1Endpoint, worker2Endpoint }); 26 | 27 | // Act 28 | var result = sut.GetEndpoints(workerId1); 29 | 30 | // Assert 31 | var resultEndpoint = Assert.Single(result); 32 | Assert.Same(worker1Endpoint, resultEndpoint); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Execution/ExternalTaskProcessingService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using Camunda.Worker.Client; 5 | using Microsoft.Extensions.DependencyInjection; 6 | 7 | namespace Camunda.Worker.Execution; 8 | 9 | internal sealed class ExternalTaskProcessingService : IExternalTaskProcessingService 10 | { 11 | private readonly IServiceProvider _serviceProvider; 12 | private readonly ExternalTaskDelegate _externalTaskDelegate; 13 | 14 | public ExternalTaskProcessingService( 15 | IServiceProvider serviceProvider, 16 | ExternalTaskDelegate externalTaskDelegate 17 | ) 18 | { 19 | _serviceProvider = serviceProvider; 20 | _externalTaskDelegate = externalTaskDelegate; 21 | } 22 | 23 | public async Task ProcessAsync( 24 | ExternalTask externalTask, 25 | IExternalTaskClient externalTaskClient, 26 | CancellationToken cancellationToken 27 | ) 28 | { 29 | await using var scope = _serviceProvider.CreateAsyncScope(); 30 | 31 | var context = new ExternalTaskContext( 32 | externalTask, 33 | externalTaskClient, 34 | scope.ServiceProvider, 35 | cancellationToken 36 | ); 37 | 38 | await _externalTaskDelegate(context); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Camunda.Worker 2 | 3 | [![codecov](https://codecov.io/gh/AMalininHere/camunda-worker-dotnet/branch/master/graph/badge.svg)](https://codecov.io/gh/AMalininHere/camunda-worker-dotnet) 4 | [![NuGet](https://img.shields.io/nuget/v/Camunda.Worker.svg)](https://www.nuget.org/packages/Camunda.Worker) 5 | 6 | ## Example 7 | 8 | ```csharp 9 | [HandlerTopics("sayHello", LockDuration = 10_000)] 10 | [HandlerVariables("USERNAME")] 11 | public class SayHelloHandler : IExternalTaskHandler 12 | { 13 | public async Task HandleAsync(ExternalTask externalTask, CancellationToken cancellationToken) 14 | { 15 | if (!externalTask.TryGetVariable("USERNAME", out var usernameVariable)) 16 | { 17 | return new BpmnErrorResult("NO_USER", "Username not provided"); 18 | } 19 | 20 | var username = usernameVariable.Value; 21 | 22 | await Task.Delay(1000, cancellationToken); 23 | 24 | return new CompleteResult 25 | { 26 | Variables = new Dictionary 27 | { 28 | ["MESSAGE"] = new StringVariable($"Hello, {username}!"), 29 | ["USER_INFO"] = JsonVariable.Create(new UserInfo(username, new List 30 | { 31 | "Admin" 32 | })) 33 | } 34 | }; 35 | } 36 | } 37 | ``` 38 | -------------------------------------------------------------------------------- /src/Camunda.Worker/WorkerIdString.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | 4 | namespace Camunda.Worker; 5 | 6 | public readonly struct WorkerIdString : IEquatable 7 | { 8 | private const string DefaultValue = "camunda-worker-dotnet"; 9 | 10 | [SuppressMessage("ReSharper", "UnusedMember.Global")] 11 | public WorkerIdString() 12 | { 13 | Value = DefaultValue; 14 | } 15 | 16 | [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] 17 | public WorkerIdString(string value) 18 | { 19 | Value = Guard.NotEmptyAndNotNull(value, nameof(value)); 20 | } 21 | 22 | public string Value { get; } 23 | 24 | public static implicit operator WorkerIdString(string value) => new(value); 25 | 26 | public bool Equals(WorkerIdString other) 27 | { 28 | return Value == other.Value; 29 | } 30 | 31 | public override bool Equals(object? obj) 32 | { 33 | return obj is WorkerIdString other && Equals(other); 34 | } 35 | 36 | public override int GetHashCode() 37 | { 38 | return Value.GetHashCode(); 39 | } 40 | 41 | public static bool operator ==(WorkerIdString left, WorkerIdString right) 42 | { 43 | return left.Equals(right); 44 | } 45 | 46 | public static bool operator !=(WorkerIdString left, WorkerIdString right) 47 | { 48 | return !(left == right); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Camunda.Worker.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Camunda.Worker 5 | false 6 | 12 7 | enable 8 | net8.0;net9.0 9 | 10 | 11 | 12 | 13 | 14 | all 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | all 25 | runtime; build; native; contentfiles; analyzers; buildtransitive 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Execution/HandlerInvoker.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.Logging; 5 | using Microsoft.Extensions.Logging.Abstractions; 6 | 7 | namespace Camunda.Worker.Execution; 8 | 9 | public class HandlerInvoker 10 | { 11 | private readonly IExternalTaskHandler _handler; 12 | private readonly IExternalTaskContext _context; 13 | private readonly ILogger _logger; 14 | 15 | public HandlerInvoker(IExternalTaskHandler handler, IExternalTaskContext context) 16 | { 17 | _handler = Guard.NotNull(handler, nameof(handler)); 18 | _context = Guard.NotNull(context, nameof(context)); 19 | _logger = context.ServiceProvider.GetService>() ?? 20 | NullLogger.Instance; 21 | } 22 | 23 | public async Task InvokeAsync() 24 | { 25 | _logger.LogInvoker_StartedProcessing(_context.Task.Id); 26 | IExecutionResult executionResult; 27 | try 28 | { 29 | executionResult = await _handler.HandleAsync(_context.Task, _context.ProcessingAborted); 30 | } 31 | catch (Exception e) when (!_context.ProcessingAborted.IsCancellationRequested) 32 | { 33 | _logger.LogInvoker_FailedProcessing(_context.Task.Id, e); 34 | executionResult = new FailureResult(e); 35 | } 36 | 37 | await executionResult.ExecuteResultAsync(_context); 38 | _logger.LogInvoker_FinishedProcessing(_context.Task.Id); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/FailureResultTest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Bogus; 4 | using Camunda.Worker.Client; 5 | using Moq; 6 | using Xunit; 7 | 8 | namespace Camunda.Worker; 9 | 10 | public class FailureResultTest 11 | { 12 | private readonly ExternalTask _externalTask; 13 | private readonly Mock _clientMock = new(); 14 | private readonly Mock _contextMock = new(); 15 | 16 | public FailureResultTest() 17 | { 18 | _externalTask = new Faker() 19 | .CustomInstantiator(faker => new ExternalTask( 20 | faker.Random.Guid().ToString(), 21 | faker.Random.Word(), 22 | faker.Random.Word()) 23 | ) 24 | .Generate(); 25 | _contextMock.Setup(ctx => ctx.Task).Returns(_externalTask); 26 | _contextMock.Setup(ctx => ctx.Client).Returns(_clientMock.Object); 27 | } 28 | 29 | [Fact] 30 | public async Task TestExecuteResultAsync() 31 | { 32 | // Arrange 33 | _clientMock 34 | .Setup(client => client.ReportFailureAsync( 35 | _externalTask.Id, It.IsAny(), default 36 | )) 37 | .Returns(Task.CompletedTask) 38 | .Verifiable(); 39 | 40 | var result = new FailureResult(new Exception("Message")); 41 | 42 | // Act 43 | await result.ExecuteResultAsync(_contextMock.Object); 44 | 45 | // Assert 46 | _clientMock.Verify(); 47 | _clientMock.VerifyNoOtherCalls(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /samples/SampleCamundaWorker/Handlers/SayHelloHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using Camunda.Worker; 5 | using Camunda.Worker.Variables; 6 | 7 | namespace SampleCamundaWorker.Handlers; 8 | 9 | [HandlerTopics("sayHello", LockDuration = 10000)] 10 | [HandlerVariables("USERNAME")] 11 | public class SayHelloHandler : IExternalTaskHandler 12 | { 13 | public async Task HandleAsync(ExternalTask externalTask, CancellationToken cancellationToken) 14 | { 15 | if (!externalTask.TryGetVariable("USERNAME", out var usernameVariable)) 16 | { 17 | return new BpmnErrorResult("NO_USER", "Username not provided"); 18 | } 19 | 20 | var username = usernameVariable.Value; 21 | 22 | await Task.Delay(1000, cancellationToken); 23 | 24 | return new CompleteResult 25 | { 26 | Variables = new Dictionary 27 | { 28 | ["MESSAGE"] = new StringVariable($"Hello, {username}!"), 29 | ["USER_INFO"] = JsonVariable.Create(new UserInfo(username, new List 30 | { 31 | "Admin" 32 | })) 33 | } 34 | }; 35 | } 36 | 37 | public class UserInfo 38 | { 39 | public UserInfo(string username, List roles) 40 | { 41 | Username = username; 42 | Roles = roles; 43 | } 44 | 45 | public string Username { get; } 46 | 47 | public List Roles { get; } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Routing/TopicBasedEndpointResolverTest.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Threading.Tasks; 3 | using Bogus; 4 | using Camunda.Worker.Endpoints; 5 | using Moq; 6 | using Xunit; 7 | 8 | namespace Camunda.Worker.Routing; 9 | 10 | public class TopicBasedEndpointResolverTest 11 | { 12 | [Fact] 13 | public void TestResolveKnownEndpoint() 14 | { 15 | var workerId = new Faker().Lorem.Word(); 16 | var endpointsCollectionMock = new Mock(); 17 | endpointsCollectionMock.Setup(e => e.GetEndpoints(workerId)) 18 | .Returns(new[] 19 | { 20 | new Endpoint( 21 | _ => Task.CompletedTask, 22 | new EndpointMetadata(new[] { "topic1" }), 23 | workerId 24 | ) 25 | }); 26 | 27 | var provider = new TopicBasedEndpointResolver(workerId, endpointsCollectionMock.Object); 28 | 29 | var endpoint = provider.Resolve(new ExternalTask("test", "test", "topic1")); 30 | Assert.NotNull(endpoint); 31 | } 32 | 33 | [Fact] 34 | public void TestResolveUnknownEndpoint() 35 | { 36 | var endpointsCollectionMock = new Mock(); 37 | endpointsCollectionMock.Setup(e => e.GetEndpoints(It.IsAny())) 38 | .Returns(Enumerable.Empty()); 39 | 40 | var provider = new TopicBasedEndpointResolver(new Faker().Lorem.Word(), endpointsCollectionMock.Object); 41 | var endpoint = provider.Resolve(new ExternalTask("test", "test", "topic1")); 42 | 43 | Assert.Null(endpoint); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Camunda.Worker/CompleteResult.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Net; 3 | using System.Threading.Tasks; 4 | using Camunda.Worker.Client; 5 | using Camunda.Worker.Variables; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using Microsoft.Extensions.Logging; 8 | using Microsoft.Extensions.Logging.Abstractions; 9 | 10 | namespace Camunda.Worker; 11 | 12 | public sealed class CompleteResult : IExecutionResult 13 | { 14 | public CompleteResult() 15 | { 16 | } 17 | 18 | public Dictionary? Variables { get; set; } 19 | 20 | public Dictionary? LocalVariables { get; set; } 21 | 22 | public async Task ExecuteResultAsync(IExternalTaskContext context) 23 | { 24 | var externalTask = context.Task; 25 | var client = context.Client; 26 | 27 | try 28 | { 29 | await client.CompleteAsync(externalTask.Id, new CompleteRequest(externalTask.WorkerId) 30 | { 31 | Variables = Variables, 32 | LocalVariables = LocalVariables, 33 | }); 34 | } 35 | catch (ClientException e) when (e.StatusCode == HttpStatusCode.InternalServerError) 36 | { 37 | var logger = context.ServiceProvider.GetService>(); 38 | logger?.LogResult_FailedCompletion(externalTask.Id, e.Message, e); 39 | await client.ReportFailureAsync(externalTask.Id, new ReportFailureRequest(externalTask.WorkerId) 40 | { 41 | ErrorMessage = e.ErrorType, 42 | ErrorDetails = e.ErrorMessage, 43 | }); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Routing/ExternalTaskRouterTest.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading.Tasks; 3 | using Camunda.Worker.Endpoints; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Moq; 6 | using Xunit; 7 | 8 | namespace Camunda.Worker.Routing; 9 | 10 | public class ExternalTaskRouterTest 11 | { 12 | private const string _workerId = "testWorker"; 13 | private readonly Mock _contextMock = new(); 14 | private readonly Mock _endpointResolverMock = new(); 15 | 16 | public ExternalTaskRouterTest() 17 | { 18 | var serviceProvider = new ServiceCollection() 19 | .AddKeyedSingleton(_workerId, _endpointResolverMock.Object) 20 | .BuildServiceProvider(); 21 | 22 | _contextMock.SetupGet(context => context.ServiceProvider).Returns(serviceProvider); 23 | _contextMock.SetupGet(context => context.Task).Returns(new ExternalTask("1", _workerId, "testTopic")); 24 | } 25 | 26 | [Fact] 27 | public async Task TestRouteAsync() 28 | { 29 | // Arrange 30 | var calls = new List(); 31 | 32 | var endpoint = new Endpoint( 33 | context => 34 | { 35 | calls.Add(context); 36 | return Task.CompletedTask; 37 | }, 38 | new EndpointMetadata(new []{ "testTopic" }), 39 | "testWorker" 40 | ); 41 | 42 | _endpointResolverMock.Setup(factory => factory.Resolve(It.IsAny())) 43 | .Returns(endpoint); 44 | 45 | // Act 46 | await ExternalTaskRouter.RouteAsync(_contextMock.Object); 47 | 48 | // Assert 49 | Assert.Single(calls); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Endpoints/EndpointMetadata.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace Camunda.Worker.Endpoints; 4 | 5 | public class EndpointMetadata 6 | { 7 | public EndpointMetadata(IReadOnlyList topicNames, int lockDuration = Constants.MinimumLockDuration) 8 | { 9 | TopicNames = Guard.NotNull(topicNames, nameof(topicNames)); 10 | LockDuration = Guard.GreaterThanOrEqual(lockDuration, Constants.MinimumLockDuration, nameof(lockDuration)); 11 | } 12 | 13 | public IReadOnlyList TopicNames { get; } 14 | 15 | public int LockDuration { get; } 16 | 17 | public bool LocalVariables { get; set; } 18 | 19 | /// Determines whether serializable variable values (typically variables that store custom Java objects) should be deserialized on server side (default false). 20 | public bool DeserializeValues { get; set; } 21 | 22 | /// Determines whether custom extension properties defined in the BPMN activity of the external task (e.g. via the Extensions tab in the Camunda modeler) should be included in the response. Default: false. 23 | public bool IncludeExtensionProperties { get; set; } 24 | 25 | public IReadOnlyList? Variables { get; set; } 26 | 27 | public IReadOnlyList? ProcessDefinitionIds { get; set; } 28 | 29 | public IReadOnlyList? ProcessDefinitionKeys { get; set; } 30 | 31 | /// A dictionary used for filtering tasks based on process instance variable values. The property Key of the dictionary represents a process variable name, while the property value represents the process variable value to filter tasks by. 32 | public IReadOnlyDictionary? ProcessVariables { get; set; } 33 | 34 | public IReadOnlyList? TenantIds { get; set; } 35 | } 36 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/ServiceCollectionExtensionsTest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Camunda.Worker.Client; 3 | using Camunda.Worker.Execution; 4 | using Camunda.Worker.Routing; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using Microsoft.Extensions.Options; 7 | using Xunit; 8 | 9 | namespace Camunda.Worker; 10 | 11 | public class ServiceCollectionExtensionsTest 12 | { 13 | [Theory] 14 | [InlineData("testWorker")] 15 | public void Should_RegisterRequiredKeyedServices(string workerId) 16 | { 17 | var services = new ServiceCollection(); 18 | 19 | services.AddCamundaWorker(workerId, 100); 20 | 21 | Assert.Contains(services, IsRegistered(typeof(IEndpointResolver), ServiceLifetime.Singleton, workerId)); 22 | Assert.Contains(services, IsRegistered(typeof(ICamundaWorker), ServiceLifetime.Transient, workerId)); 23 | Assert.Contains(services, IsRegistered(typeof(IExternalTaskProcessingService), ServiceLifetime.Singleton, workerId)); 24 | Assert.Contains(services, IsRegistered(typeof(IFetchAndLockRequestProvider), ServiceLifetime.Singleton, workerId)); 25 | 26 | // IEqternalTaskClient should be registered separately 27 | services.AddExternalTaskClient(options => 28 | { 29 | options.BaseAddress = new Uri("http://test"); 30 | }); 31 | 32 | using var provider = services.BuildServiceProvider(); 33 | var registeredWorker = provider.GetRequiredKeyedService(workerId); 34 | } 35 | 36 | private static Predicate IsRegistered(Type serviceType, ServiceLifetime lifetime, string workerId) 37 | => descriptor => descriptor.Lifetime == lifetime && 38 | descriptor.ServiceType == serviceType && 39 | descriptor.IsKeyedService && 40 | workerId.Equals(descriptor.ServiceKey); 41 | } 42 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Log.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.Extensions.Logging; 3 | 4 | namespace Camunda.Worker; 5 | 6 | internal static partial class Log 7 | { 8 | #region Worker 9 | 10 | [LoggerMessage(LogLevel.Debug, "Waiting for external task")] 11 | public static partial void LogWorker_Waiting(this ILogger logger); 12 | 13 | [LoggerMessage(LogLevel.Debug, "Locked {Count} external tasks")] 14 | public static partial void LogWorker_Locked(this ILogger logger, int count); 15 | 16 | [LoggerMessage(LogLevel.Warning, "Failed locking of external tasks. Reason: {Reason}")] 17 | public static partial void LogWorker_FailedLocking(this ILogger logger, string reason, Exception e); 18 | 19 | [LoggerMessage(LogLevel.Warning, "Failed execution of task {ExternalTaskId}")] 20 | public static partial void LogWorker_FailedExecution(this ILogger logger, string externalTaskId, Exception e); 21 | 22 | #endregion 23 | 24 | #region Invoker 25 | 26 | [LoggerMessage(LogLevel.Debug, "Started processing of task {ExternalTaskId}")] 27 | public static partial void LogInvoker_StartedProcessing(this ILogger logger, string externalTaskId); 28 | 29 | [LoggerMessage(LogLevel.Debug, "Finished processing of task {ExternalTaskId}")] 30 | public static partial void LogInvoker_FinishedProcessing(this ILogger logger, string externalTaskId); 31 | 32 | [LoggerMessage(LogLevel.Error, "Failed processing of task {ExternalTaskId}")] 33 | public static partial void LogInvoker_FailedProcessing(this ILogger logger, string externalTaskId, Exception e); 34 | 35 | #endregion 36 | 37 | [LoggerMessage(LogLevel.Warning, "Failed completion of task {ExternalTaskId}. Reason: {Reason}")] 38 | public static partial void LogResult_FailedCompletion( 39 | this ILogger logger, 40 | string externalTaskId, 41 | string reason, 42 | Exception e 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Camunda.Worker.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Alexey Malinin 5 | Ultimate solution to connect your ASP.NET Core application to Camunda external tasks 6 | camunda;bpmn;worker 7 | https://github.com/TechnoBerry/camunda-worker-dotnet 8 | MIT 9 | 12 10 | enable 11 | true 12 | true 13 | snupkg 14 | net8.0;net9.0 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/Camunda.Worker/ServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using Camunda.Worker.Client; 2 | using Camunda.Worker.Endpoints; 3 | using Camunda.Worker.Execution; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Microsoft.Extensions.DependencyInjection.Extensions; 6 | using Microsoft.Extensions.Hosting; 7 | using Microsoft.Extensions.Logging; 8 | using Microsoft.Extensions.Options; 9 | 10 | namespace Camunda.Worker; 11 | 12 | public static class CamundaWorkerServiceCollectionExtensions 13 | { 14 | public static ICamundaWorkerBuilder AddCamundaWorker( 15 | this IServiceCollection services, 16 | WorkerIdString workerId, 17 | int numberOfWorkers = Constants.MinimumParallelExecutors 18 | ) 19 | { 20 | Guard.GreaterThanOrEqual(numberOfWorkers, Constants.MinimumParallelExecutors, nameof(numberOfWorkers)); 21 | 22 | services.AddOptions(workerId.Value); 23 | services.AddOptions(workerId.Value); 24 | 25 | services.TryAddSingleton(); 26 | services.TryAddKeyedTransient(workerId.Value, (provider, key) => new DefaultCamundaWorker( 27 | workerId, 28 | provider.GetRequiredService(), 29 | provider.GetRequiredKeyedService(workerId.Value), 30 | provider.GetRequiredService>(), 31 | provider, 32 | provider.GetRequiredKeyedService(workerId.Value), 33 | provider.GetService>() 34 | )); 35 | services.AddTransient(provider => new WorkerHostedService(provider, workerId, numberOfWorkers)); 36 | 37 | return new CamundaWorkerBuilder(services, workerId) 38 | .AddDefaultFetchAndLockRequestProvider() 39 | .AddDefaultEndpointResolver() 40 | .ConfigurePipeline(_ => { }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Camunda.Worker/BpmnErrorResult.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Net; 3 | using System.Threading.Tasks; 4 | using Camunda.Worker.Client; 5 | using Camunda.Worker.Variables; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using Microsoft.Extensions.Logging; 8 | using Microsoft.Extensions.Logging.Abstractions; 9 | 10 | namespace Camunda.Worker; 11 | 12 | public sealed class BpmnErrorResult : IExecutionResult 13 | { 14 | public BpmnErrorResult(string errorCode, string errorMessage, Dictionary? variables = null) 15 | { 16 | ErrorCode = Guard.NotEmptyAndNotNull(errorCode, nameof(errorCode)); 17 | ErrorMessage = Guard.NotEmptyAndNotNull(errorMessage, nameof(errorMessage)); 18 | Variables = variables; 19 | } 20 | 21 | public string ErrorCode { get; } 22 | public string ErrorMessage { get; } 23 | public Dictionary? Variables { get; } 24 | 25 | public async Task ExecuteResultAsync(IExternalTaskContext context) 26 | { 27 | var externalTask = context.Task; 28 | var client = context.Client; 29 | 30 | try 31 | { 32 | await client.ReportBpmnErrorAsync( 33 | externalTask.Id, 34 | new BpmnErrorRequest(externalTask.WorkerId, ErrorCode, ErrorMessage) 35 | { 36 | Variables = Variables, 37 | } 38 | ); 39 | } 40 | catch (ClientException e) when (e.StatusCode == HttpStatusCode.InternalServerError) 41 | { 42 | var logger = context.ServiceProvider.GetService>(); 43 | logger?.LogResult_FailedCompletion(externalTask.Id, e.Message, e); 44 | await client.ReportFailureAsync(externalTask.Id, new ReportFailureRequest(externalTask.WorkerId) 45 | { 46 | ErrorMessage = e.ErrorType, 47 | ErrorDetails = e.ErrorMessage, 48 | }); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/PipelineBuilderTest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Bogus; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using Moq; 8 | using Xunit; 9 | 10 | namespace Camunda.Worker; 11 | 12 | public class PipelineBuilderTest 13 | { 14 | private readonly Mock _contextMock = new(); 15 | private readonly IServiceProvider _serviceProvider; 16 | 17 | public PipelineBuilderTest() 18 | { 19 | var services = new ServiceCollection(); 20 | _serviceProvider = services.BuildServiceProvider(); 21 | _contextMock.SetupGet(ctx => ctx.ServiceProvider) 22 | .Returns(_serviceProvider); 23 | } 24 | 25 | [Theory] 26 | [InlineData(10)] 27 | [InlineData(100)] 28 | public async Task TestBuildPipeline(int calls) 29 | { 30 | IPipelineBuilder builder = new PipelineBuilder(_serviceProvider, new Faker().Random.String()); 31 | 32 | Task LastDelegate(IExternalTaskContext context) 33 | { 34 | return Task.CompletedTask; 35 | } 36 | 37 | var numsIn = new List(calls); 38 | var numsOut = new List(calls); 39 | 40 | Enumerable.Range(0, calls) 41 | .Select(i => (Func) (next => async ctx => 42 | { 43 | numsIn.Add(i); 44 | 45 | await next(ctx); 46 | 47 | numsOut.Add(i); 48 | })) 49 | .Aggregate(builder, (b, func) => b.Use(func)); 50 | 51 | var result = builder.Build(LastDelegate); 52 | await result(_contextMock.Object); 53 | 54 | Assert.Equal(calls, numsIn.Count); 55 | Assert.Equal(calls, numsOut.Count); 56 | 57 | Assert.Equal(numsIn.Count, numsIn.Distinct().Count()); 58 | Assert.Equal(numsOut.Count, numsOut.Distinct().Count()); 59 | 60 | Assert.Equal(numsIn, ((IEnumerable) numsOut).Reverse().ToList()); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Execution/FetchAndLockRequestProvider.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using Camunda.Worker.Client; 4 | using Camunda.Worker.Endpoints; 5 | using Microsoft.Extensions.Options; 6 | 7 | namespace Camunda.Worker.Execution; 8 | 9 | public sealed class FetchAndLockRequestProvider : IFetchAndLockRequestProvider 10 | { 11 | private readonly WorkerIdString _workerId; 12 | private readonly FetchAndLockOptions _options; 13 | private readonly Endpoint[] _endpoints; 14 | 15 | public FetchAndLockRequestProvider( 16 | WorkerIdString workerId, 17 | IOptionsMonitor options, 18 | IEndpointsCollection endpointsCollection 19 | ) 20 | { 21 | _workerId = workerId; 22 | _options = options.Get(workerId.Value); 23 | _endpoints = endpointsCollection.GetEndpoints(workerId).ToArray(); 24 | } 25 | 26 | public FetchAndLockRequest GetRequest() 27 | { 28 | var topics = GetTopics(); 29 | 30 | var fetchAndLockRequest = new FetchAndLockRequest(_workerId.Value, _options.MaxTasks) 31 | { 32 | UsePriority = _options.UsePriority, 33 | AsyncResponseTimeout = _options.AsyncResponseTimeout, 34 | Topics = topics 35 | }; 36 | 37 | return fetchAndLockRequest; 38 | } 39 | 40 | private List GetTopics() 41 | { 42 | var topics = new List(_endpoints.Length); 43 | 44 | foreach (var endpoint in _endpoints) 45 | { 46 | foreach (var topicName in endpoint.Metadata.TopicNames) 47 | { 48 | topics.Add(MakeTopicRequest(endpoint.Metadata, topicName)); 49 | } 50 | } 51 | 52 | return topics; 53 | } 54 | 55 | private static FetchAndLockRequest.Topic MakeTopicRequest(EndpointMetadata metadata, string topicName) => 56 | new(topicName, metadata.LockDuration) 57 | { 58 | LocalVariables = metadata.LocalVariables, 59 | Variables = metadata.Variables, 60 | ProcessDefinitionIdIn = metadata.ProcessDefinitionIds, 61 | ProcessDefinitionKeyIn = metadata.ProcessDefinitionKeys, 62 | ProcessVariables = metadata.ProcessVariables, 63 | TenantIdIn = metadata.TenantIds, 64 | DeserializeValues = metadata.DeserializeValues, 65 | IncludeExtensionProperties = metadata.IncludeExtensionProperties, 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /samples/SampleCamundaWorker/Startup.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Camunda.Worker; 3 | using Camunda.Worker.Client; 4 | using Microsoft.AspNetCore.Builder; 5 | using Microsoft.AspNetCore.Hosting; 6 | using Microsoft.Extensions.Configuration; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Microsoft.Extensions.Hosting; 9 | using Microsoft.Extensions.Logging; 10 | using SampleCamundaWorker.Handlers; 11 | 12 | namespace SampleCamundaWorker; 13 | 14 | public class Startup 15 | { 16 | public Startup(IConfiguration configuration) 17 | { 18 | Configuration = configuration; 19 | } 20 | 21 | public IConfiguration Configuration { get; } 22 | 23 | public void ConfigureServices(IServiceCollection services) 24 | { 25 | services.AddExternalTaskClient(client => 26 | { 27 | client.BaseAddress = new Uri("http://localhost:8080/engine-rest"); 28 | }); 29 | 30 | services.AddCamundaWorker("sampleWorker") 31 | .AddHandler() 32 | .ConfigurePipeline(pipeline => 33 | { 34 | pipeline.Use(next => async context => 35 | { 36 | var logger = context.ServiceProvider.GetRequiredService>(); 37 | logger.LogInformation("Started processing of task {Id} by worker {WorkerId}", context.Task.Id, context.Task.WorkerId); 38 | await next(context); 39 | logger.LogInformation("Finished processing of task {Id}", context.Task.Id); 40 | }); 41 | }); 42 | 43 | services.AddCamundaWorker("sampleWorker2") 44 | .AddHandler() 45 | .ConfigurePipeline(pipeline => 46 | { 47 | pipeline.Use(next => async context => 48 | { 49 | var logger = context.ServiceProvider.GetRequiredService>(); 50 | logger.LogInformation("Started processing of task {Id} by worker {WorkerId}", context.Task.Id, context.Task.WorkerId); 51 | await next(context); 52 | logger.LogInformation("Finished processing of task {Id}", context.Task.Id); 53 | }); 54 | }); 55 | 56 | services.AddHealthChecks(); 57 | } 58 | 59 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env) 60 | { 61 | if (env.IsDevelopment()) 62 | { 63 | app.UseDeveloperExceptionPage(); 64 | } 65 | 66 | app.UseHealthChecks("/health"); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Camunda.Worker/CamundaWorkerBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reflection; 3 | using System.Threading.Tasks; 4 | using Camunda.Worker.Endpoints; 5 | using Camunda.Worker.Execution; 6 | using Microsoft.Extensions.DependencyInjection; 7 | 8 | namespace Camunda.Worker; 9 | 10 | public static class CamundaWorkerBuilderExtensions 11 | { 12 | public static ICamundaWorkerBuilder AddHandler( 13 | this ICamundaWorkerBuilder builder, 14 | Action? configureMetadata = null 15 | ) 16 | where T : class, IExternalTaskHandler 17 | { 18 | Guard.NotNull(builder, nameof(builder)); 19 | 20 | var metadata = CollectMetadataFromAttributes(typeof(T)); 21 | configureMetadata?.Invoke(metadata); 22 | 23 | return builder.AddHandler(metadata); 24 | } 25 | 26 | private static EndpointMetadata CollectMetadataFromAttributes(Type handlerType) 27 | { 28 | var topicsAttribute = handlerType.GetCustomAttribute(); 29 | 30 | if (topicsAttribute == null) 31 | { 32 | throw new ArgumentException( 33 | $"\"{handlerType.FullName}\" doesn't provide any \"HandlerTopicsAttribute\"", 34 | nameof(handlerType) 35 | ); 36 | } 37 | 38 | var variablesAttribute = handlerType.GetCustomAttribute(); 39 | 40 | return new EndpointMetadata(topicsAttribute.TopicNames, topicsAttribute.LockDuration) 41 | { 42 | LocalVariables = variablesAttribute?.LocalVariables ?? false, 43 | Variables = variablesAttribute?.AllVariables ?? false ? null : variablesAttribute?.Variables, 44 | IncludeExtensionProperties = topicsAttribute.IncludeExtensionProperties 45 | }; 46 | } 47 | 48 | public static ICamundaWorkerBuilder AddHandler(this ICamundaWorkerBuilder builder, EndpointMetadata metadata) 49 | where T : class, IExternalTaskHandler 50 | { 51 | Guard.NotNull(builder, nameof(builder)); 52 | Guard.NotNull(metadata, nameof(metadata)); 53 | 54 | var services = builder.Services; 55 | services.AddTransient(); 56 | 57 | return builder.AddHandler(HandlerDelegate, metadata); 58 | } 59 | 60 | private static Task HandlerDelegate(IExternalTaskContext context) 61 | where T : class, IExternalTaskHandler 62 | { 63 | var handler = context.ServiceProvider.GetRequiredService(); 64 | var invoker = new HandlerInvoker(handler, context); 65 | return invoker.InvokeAsync(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Client/Serialization/VariableJsonConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json; 3 | using System.Text.Json.Serialization; 4 | using Camunda.Worker.Variables; 5 | 6 | namespace Camunda.Worker.Client.Serialization; 7 | 8 | public class VariableJsonConverter : JsonConverter 9 | { 10 | public override VariableBase? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 11 | { 12 | if (reader.TokenType == JsonTokenType.Null) return null; 13 | 14 | using var jsonDocument = JsonDocument.ParseValue(ref reader); 15 | var rootElement = jsonDocument.RootElement; 16 | var variableType = rootElement.GetProperty("type").GetString(); 17 | 18 | return variableType switch 19 | { 20 | "String" => rootElement.Deserialize(options), 21 | "Boolean" => rootElement.Deserialize(options), 22 | "Short" => rootElement.Deserialize(options), 23 | "Integer" => rootElement.Deserialize(options), 24 | "Long" => rootElement.Deserialize(options), 25 | "Double" => rootElement.Deserialize(options), 26 | "Bytes" => rootElement.Deserialize(options), 27 | "Null" => rootElement.Deserialize(options), 28 | "Json" => rootElement.Deserialize(options), 29 | "Xml" => rootElement.Deserialize(options), 30 | _ => rootElement.Deserialize(options) 31 | }; 32 | } 33 | 34 | public override void Write(Utf8JsonWriter writer, VariableBase value, JsonSerializerOptions options) 35 | { 36 | var jsonNode = JsonSerializer.SerializeToNode(value, value.GetType(), options) 37 | ?? throw new JsonException(); 38 | 39 | jsonNode["type"] = value switch 40 | { 41 | StringVariable => "String", 42 | BooleanVariable => "Boolean", 43 | ShortVariable => "Short", 44 | IntegerVariable => "Integer", 45 | LongVariable => "Long", 46 | DoubleVariable => "Double", 47 | BytesVariable => "Bytes", 48 | NullVariable => "Null", 49 | JsonVariable => "Json", 50 | XmlVariable => "Xml", 51 | UnknownVariable unknownVariable => unknownVariable.Type, 52 | _ => throw new ArgumentOutOfRangeException(nameof(value), value, null) 53 | }; 54 | 55 | jsonNode.WriteTo(writer, options); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/BpmnErrorResultTest.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Threading.Tasks; 3 | using Bogus; 4 | using Camunda.Worker.Client; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using Moq; 7 | using Xunit; 8 | 9 | namespace Camunda.Worker; 10 | 11 | public class BpmnErrorResultTest 12 | { 13 | private readonly ExternalTask _externalTask; 14 | private readonly Mock _clientMock = new(); 15 | private readonly Mock _contextMock = new(); 16 | 17 | public BpmnErrorResultTest() 18 | { 19 | _externalTask = new Faker() 20 | .CustomInstantiator(faker => new ExternalTask( 21 | faker.Random.Guid().ToString(), 22 | faker.Random.Word(), 23 | faker.Random.Word()) 24 | ) 25 | .Generate(); 26 | _contextMock.Setup(ctx => ctx.Task).Returns(_externalTask); 27 | _contextMock.Setup(ctx => ctx.Client).Returns(_clientMock.Object); 28 | _contextMock.SetupGet(c => c.ServiceProvider).Returns(new ServiceCollection().BuildServiceProvider()); 29 | } 30 | 31 | [Fact] 32 | public async Task TestExecuteResultAsync() 33 | { 34 | // Arrange 35 | _clientMock 36 | .Setup(client => client.ReportBpmnErrorAsync( 37 | _externalTask.Id, It.IsAny(), default 38 | )) 39 | .Returns(Task.CompletedTask) 40 | .Verifiable(); 41 | 42 | var result = new BpmnErrorResult("TEST_CODE", "Test message"); 43 | 44 | // Act 45 | await result.ExecuteResultAsync(_contextMock.Object); 46 | 47 | // Assert 48 | _clientMock.Verify(); 49 | _clientMock.VerifyNoOtherCalls(); 50 | } 51 | 52 | [Fact] 53 | public async Task TestExecuteResultWithFailedCompletion() 54 | { 55 | // Arrange 56 | _clientMock 57 | .Setup(client => client.ReportBpmnErrorAsync( 58 | _externalTask.Id, It.IsAny(), default 59 | )) 60 | .ThrowsAsync(new ClientException(new ErrorResponse 61 | { 62 | Type = "an error type", 63 | Message = "an error message" 64 | }, HttpStatusCode.InternalServerError)) 65 | .Verifiable(); 66 | 67 | _clientMock 68 | .Setup(client => client.ReportFailureAsync( 69 | _externalTask.Id, It.IsAny(), default 70 | )) 71 | .Returns(Task.CompletedTask) 72 | .Verifiable(); 73 | 74 | var result = new BpmnErrorResult("TEST_CODE", "Test message"); 75 | 76 | // Act 77 | await result.ExecuteResultAsync(_contextMock.Object); 78 | 79 | // Assert 80 | _clientMock.Verify(); 81 | _clientMock.VerifyNoOtherCalls(); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/CompleteResultTest.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Net; 3 | using System.Threading.Tasks; 4 | using Bogus; 5 | using Camunda.Worker.Client; 6 | using Camunda.Worker.Variables; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Moq; 9 | using Xunit; 10 | 11 | namespace Camunda.Worker; 12 | 13 | public class CompleteResultTest 14 | { 15 | private readonly ExternalTask _externalTask; 16 | private readonly Mock _clientMock = new(); 17 | private readonly Mock _contextMock = new(); 18 | 19 | public CompleteResultTest() 20 | { 21 | _externalTask = new Faker() 22 | .CustomInstantiator(faker => new ExternalTask( 23 | faker.Random.Guid().ToString(), 24 | faker.Random.Word(), 25 | faker.Random.Word()) 26 | ) 27 | .Generate(); 28 | _contextMock.Setup(ctx => ctx.Task).Returns(_externalTask); 29 | _contextMock.Setup(ctx => ctx.Client).Returns(_clientMock.Object); 30 | _contextMock.SetupGet(c => c.ServiceProvider).Returns(new ServiceCollection().BuildServiceProvider()); 31 | } 32 | 33 | [Fact] 34 | public async Task TestExecuteResultAsync() 35 | { 36 | // Arrange 37 | _clientMock 38 | .Setup(client => client.CompleteAsync( 39 | _externalTask.Id, It.IsAny(), default 40 | )) 41 | .Returns(Task.CompletedTask) 42 | .Verifiable(); 43 | 44 | var result = new CompleteResult 45 | { 46 | Variables = new Dictionary() 47 | }; 48 | 49 | //Act 50 | await result.ExecuteResultAsync(_contextMock.Object); 51 | 52 | // Assert 53 | _clientMock.Verify(); 54 | _clientMock.VerifyNoOtherCalls(); 55 | } 56 | 57 | [Fact] 58 | public async Task TestExecuteResultWithFailedCompletion() 59 | { 60 | // Arrange 61 | _clientMock 62 | .Setup(client => client.CompleteAsync( 63 | _externalTask.Id, It.IsAny(), default 64 | )) 65 | .ThrowsAsync(new ClientException(new ErrorResponse 66 | { 67 | Type = "an error type", 68 | Message = "an error message" 69 | }, HttpStatusCode.InternalServerError)) 70 | .Verifiable(); 71 | 72 | _clientMock 73 | .Setup(client => client.ReportFailureAsync( 74 | _externalTask.Id, It.IsAny(), default 75 | )) 76 | .Returns(Task.CompletedTask) 77 | .Verifiable(); 78 | 79 | var result = new CompleteResult 80 | { 81 | Variables = new Dictionary() 82 | }; 83 | 84 | // Act 85 | await result.ExecuteResultAsync(_contextMock.Object); 86 | 87 | // Assert 88 | _clientMock.Verify(); 89 | _clientMock.VerifyNoOtherCalls(); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/CamundaWorkerBuilderTest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Camunda.Worker.Client; 4 | using Camunda.Worker.Endpoints; 5 | using Camunda.Worker.Execution; 6 | using Camunda.Worker.Routing; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Microsoft.Extensions.Options; 9 | using Xunit; 10 | 11 | namespace Camunda.Worker; 12 | 13 | public class CamundaWorkerBuilderTest 14 | { 15 | private readonly WorkerIdString _workerId = new("testWorker"); 16 | private readonly ServiceCollection _services = new(); 17 | private readonly CamundaWorkerBuilder _builder; 18 | 19 | public CamundaWorkerBuilderTest() 20 | { 21 | _builder = new CamundaWorkerBuilder(_services, _workerId); 22 | } 23 | 24 | [Fact] 25 | public void TestAddHandler() 26 | { 27 | Task FakeHandlerDelegate(IExternalTaskContext context) => Task.CompletedTask; 28 | 29 | _builder.AddHandler(FakeHandlerDelegate, new EndpointMetadata(new[] {"testTopic"})); 30 | 31 | Assert.Contains(_services, d => d.Lifetime == ServiceLifetime.Singleton && 32 | d.ImplementationInstance != null); 33 | } 34 | 35 | [Fact] 36 | public void TestAddEndpointResolver() 37 | { 38 | _builder.AddEndpointResolver((_, _) => new EndpointResolver()); 39 | 40 | using var provider = _services.BuildServiceProvider(); 41 | 42 | Assert.IsType(provider.GetKeyedService(_workerId.Value)); 43 | } 44 | 45 | [Fact] 46 | public void TestAddFetchAndLockRequestProvider() 47 | { 48 | _builder.AddFetchAndLockRequestProvider((_, _) => new FetchAndLockRequestProvider()); 49 | 50 | using var provider = _services.BuildServiceProvider(); 51 | 52 | Assert.IsType(provider.GetKeyedService(_workerId.Value)); 53 | } 54 | 55 | [Fact] 56 | public void TestConfigurePipeline() 57 | { 58 | _builder.ConfigurePipeline(pipeline => { }); 59 | 60 | Assert.Contains(_services, d => d.Lifetime == ServiceLifetime.Singleton && 61 | d.ServiceType == typeof(IExternalTaskProcessingService)); 62 | } 63 | 64 | [Fact] 65 | public void TestConfigureEvents() 66 | { 67 | _builder.ConfigureEvents(events => 68 | { 69 | events.OnBeforeFetchAndLock = (_, _) => Task.CompletedTask; 70 | }); 71 | 72 | Assert.Contains(_services, d => d.ServiceType == typeof(IConfigureOptions)); 73 | } 74 | 75 | private class EndpointResolver : IEndpointResolver 76 | { 77 | 78 | public Endpoint? Resolve(ExternalTask externalTask) 79 | { 80 | throw new NotImplementedException(); 81 | } 82 | } 83 | 84 | private class FetchAndLockRequestProvider : IFetchAndLockRequestProvider 85 | { 86 | public FetchAndLockRequest GetRequest() 87 | { 88 | throw new NotImplementedException(); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Camunda.Worker/ExternalTask.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Camunda.Worker.Variables; 3 | 4 | namespace Camunda.Worker; 5 | 6 | public class ExternalTask 7 | { 8 | public ExternalTask(string id, string workerId, string topicName) 9 | { 10 | Id = Guard.NotNull(id, nameof(id)); 11 | WorkerId = Guard.NotNull(workerId, nameof(workerId)); 12 | TopicName = Guard.NotNull(topicName, nameof(topicName)); 13 | } 14 | 15 | /// 16 | /// The id of the external task. 17 | /// 18 | public string Id { get; } 19 | 20 | /// 21 | /// The id of the worker that posesses or posessed the most recent lock. 22 | /// 23 | public string WorkerId { get; } 24 | 25 | /// 26 | /// The topic name of the external task. 27 | /// 28 | public string TopicName { get; } 29 | 30 | /// 31 | /// The priority of the external task 32 | /// 33 | public int Priority { get; set; } 34 | 35 | /// 36 | /// The id of the execution that the external task belongs to 37 | /// 38 | public string? ExecutionId { get; set; } 39 | 40 | /// 41 | /// The id of the activity that this external task belongs to 42 | /// 43 | public string? ActivityId { get; set; } 44 | 45 | /// 46 | /// The id of the activity instance that the external task belongs to 47 | /// 48 | public string? ActivityInstanceId { get; set; } 49 | 50 | /// 51 | /// The id of the tenant the external task belongs to 52 | /// 53 | public string? TenantId { get; set; } 54 | 55 | /// 56 | /// The id of the process definition the external task is defined in 57 | /// 58 | public string? ProcessDefinitionId { get; set; } 59 | 60 | /// 61 | /// The key of the process definition the external task is defined in 62 | /// 63 | public string? ProcessDefinitionKey { get; set; } 64 | 65 | /// 66 | /// The id of the process instance the external task belongs to 67 | /// 68 | public string? ProcessInstanceId { get; set; } 69 | 70 | /// 71 | /// The business key of the process instance the external task belongs to 72 | /// 73 | public string? BusinessKey { get; set; } 74 | 75 | /// 76 | /// The number of retries the task currently has left 77 | /// 78 | public int? Retries { get; set; } 79 | 80 | /// 81 | /// The full error message submitted with the latest reported failure executing this task 82 | /// 83 | public string? ErrorMessage { get; set; } 84 | 85 | /// 86 | /// The error details submitted with the latest reported failure executing this task 87 | /// 88 | public string? ErrorDetails { get; set; } 89 | 90 | public Dictionary? ExtensionProperties { get; set; } 91 | 92 | public Dictionary Variables { get; set; } = new(); 93 | } 94 | -------------------------------------------------------------------------------- /src/Camunda.Worker/CamundaWorkerBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Camunda.Worker.Endpoints; 3 | using Camunda.Worker.Execution; 4 | using Camunda.Worker.Routing; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using Microsoft.Extensions.Options; 7 | 8 | namespace Camunda.Worker; 9 | 10 | public class CamundaWorkerBuilder : ICamundaWorkerBuilder 11 | { 12 | public CamundaWorkerBuilder(IServiceCollection services, WorkerIdString workerId) 13 | { 14 | Services = services; 15 | WorkerId = workerId; 16 | } 17 | 18 | public IServiceCollection Services { get; } 19 | 20 | public WorkerIdString WorkerId { get; } 21 | 22 | internal CamundaWorkerBuilder AddDefaultEndpointResolver() 23 | { 24 | AddEndpointResolver((workerId, provider) => new TopicBasedEndpointResolver( 25 | workerId, 26 | provider.GetRequiredService() 27 | )); 28 | 29 | return this; 30 | } 31 | 32 | public ICamundaWorkerBuilder AddEndpointResolver(WorkerServiceFactory factory) 33 | { 34 | Services.AddKeyedSingleton(WorkerId.Value, (provider, _) => factory(WorkerId, provider)); 35 | 36 | return this; 37 | } 38 | 39 | internal CamundaWorkerBuilder AddDefaultFetchAndLockRequestProvider() 40 | { 41 | AddFetchAndLockRequestProvider((workerId, provider) => new FetchAndLockRequestProvider( 42 | workerId, 43 | provider.GetRequiredService>(), 44 | provider.GetRequiredService() 45 | )); 46 | 47 | return this; 48 | } 49 | 50 | public ICamundaWorkerBuilder AddFetchAndLockRequestProvider( 51 | WorkerServiceFactory factory 52 | ) 53 | { 54 | Services.AddKeyedSingleton(WorkerId.Value, (provider, _) => factory(WorkerId, provider)); 55 | 56 | return this; 57 | } 58 | 59 | public ICamundaWorkerBuilder AddHandler(ExternalTaskDelegate handler, EndpointMetadata endpointMetadata) 60 | { 61 | Guard.NotNull(handler, nameof(handler)); 62 | Guard.NotNull(endpointMetadata, nameof(endpointMetadata)); 63 | 64 | var endpoint = new Endpoint(handler, endpointMetadata, WorkerId); 65 | 66 | Services.AddSingleton(endpoint); 67 | return this; 68 | } 69 | 70 | public ICamundaWorkerBuilder ConfigurePipeline(Action configureAction) 71 | { 72 | Guard.NotNull(configureAction, nameof(configureAction)); 73 | Services.AddKeyedSingleton(WorkerId.Value, (provider, _) => 74 | { 75 | var externalTaskDelegate = new PipelineBuilder(provider, WorkerId) 76 | .Also(configureAction) 77 | .Build(ExternalTaskRouter.RouteAsync); 78 | return new ExternalTaskProcessingService(provider, externalTaskDelegate); 79 | }); 80 | return this; 81 | } 82 | 83 | public ICamundaWorkerBuilder ConfigureEvents(Action configureAction) 84 | { 85 | Services.Configure(WorkerId.Value, configureAction); 86 | return this; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Execution/FetchAndLockRequestProviderTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using Bogus; 5 | using Camunda.Worker.Client; 6 | using Camunda.Worker.Endpoints; 7 | using Microsoft.Extensions.Options; 8 | using Moq; 9 | using Xunit; 10 | 11 | namespace Camunda.Worker.Execution; 12 | 13 | public class FetchAndLockRequestProviderTests 14 | { 15 | private readonly Mock _endpointsCollectionMock = new(); 16 | 17 | [Fact] 18 | public void GetRequest_ShouldReturnsRequest() 19 | { 20 | // Arrange 21 | var workerId = new WorkerIdString(new Faker().Lorem.Word()); 22 | var fetchAndLockOptions = new Faker() 23 | .RuleFor(o => o.MaxTasks, f => f.Random.Int(1, 10)) 24 | .RuleFor(o => o.AsyncResponseTimeout, f => f.Random.Int(100, 10000)) 25 | .RuleFor(o => o.UsePriority, f => f.Random.Bool()) 26 | .Generate(); 27 | 28 | var endpoints = GetEndpoints(workerId); 29 | _endpointsCollectionMock.Setup(e => e.GetEndpoints(workerId)) 30 | .Returns(endpoints); 31 | 32 | var sut = new FetchAndLockRequestProvider( 33 | workerId, 34 | CreateOptions(workerId.Value, fetchAndLockOptions), 35 | _endpointsCollectionMock.Object 36 | ); 37 | 38 | // Act 39 | var request = sut.GetRequest(); 40 | 41 | // Assert 42 | Assert.NotNull(request.Topics); 43 | Assert.Collection(request.Topics, endpoints 44 | .SelectMany(endpoint => endpoint.Metadata.TopicNames.Select(topicName => (topicName, endpoint.Metadata))) 45 | .Select(pair => new Action(topic => 46 | { 47 | Assert.Equal(pair.topicName, topic.TopicName); 48 | Assert.Equal(pair.Metadata.LockDuration, topic.LockDuration); 49 | })) 50 | .ToArray() 51 | ); 52 | Assert.Equal(workerId.Value, request.WorkerId); 53 | Assert.Equal(fetchAndLockOptions.MaxTasks, request.MaxTasks); 54 | Assert.Equal(fetchAndLockOptions.AsyncResponseTimeout, request.AsyncResponseTimeout); 55 | Assert.Equal(fetchAndLockOptions.UsePriority, request.UsePriority); 56 | } 57 | 58 | private static IOptionsMonitor CreateOptions(string optionsKey, T value) 59 | { 60 | var optionsMonitorMock = new Mock>(); 61 | optionsMonitorMock 62 | .Setup(o => o.Get(optionsKey)) 63 | .Returns(value); 64 | 65 | return optionsMonitorMock.Object; 66 | } 67 | 68 | private static Endpoint[] GetEndpoints(WorkerIdString workerId) 69 | { 70 | return new[] 71 | { 72 | new Endpoint(FakeHandlerDelegate, new EndpointMetadata(["topic1"]), workerId), 73 | new Endpoint(FakeHandlerDelegate, new EndpointMetadata(["test2"], 10_000) 74 | { 75 | Variables = ["X"], 76 | LocalVariables = true 77 | }, workerId) 78 | }; 79 | 80 | static Task FakeHandlerDelegate(IExternalTaskContext context) => Task.CompletedTask; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Client/FetchAndLockRequest.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace Camunda.Worker.Client; 4 | 5 | public class FetchAndLockRequest 6 | { 7 | public FetchAndLockRequest(string workerId, int maxTasks = 1) 8 | { 9 | WorkerId = Guard.NotNull(workerId, nameof(workerId)); 10 | MaxTasks = Guard.GreaterThanOrEqual(maxTasks, 1, nameof(maxTasks)); 11 | } 12 | 13 | /// 14 | /// The id of the worker on which behalf tasks are fetched 15 | /// 16 | public string WorkerId { get; } 17 | 18 | /// 19 | /// The maximum number of tasks to return 20 | /// 21 | public int MaxTasks { get; } 22 | 23 | /// 24 | /// A value, which indicates whether the task should be fetched based on its priority or arbitrarily 25 | /// 26 | public bool UsePriority { get; set; } 27 | 28 | /// 29 | /// The long polling timeout in milliseconds 30 | /// 31 | public int AsyncResponseTimeout { get; set; } 32 | 33 | public IReadOnlyCollection? Topics { get; set; } 34 | 35 | public class Topic 36 | { 37 | public Topic(string topicName, int lockDuration) 38 | { 39 | TopicName = Guard.NotNull(topicName, nameof(topicName)); 40 | LockDuration = lockDuration; 41 | } 42 | 43 | /// 44 | /// The topic's name 45 | /// 46 | public string TopicName { get; } 47 | 48 | /// 49 | /// The duration to lock the external tasks for in milliseconds 50 | /// 51 | public int LockDuration { get; } 52 | 53 | /// 54 | /// If true only local variables will be fetched 55 | /// 56 | public bool LocalVariables { get; set; } 57 | 58 | public IReadOnlyCollection? Variables { get; set; } 59 | 60 | public string? BusinessKey { get; set; } 61 | 62 | /// Determines whether serializable variable values (typically variables that store custom Java objects) should be deserialized on server side (default false). 63 | public bool DeserializeValues { get; set; } 64 | 65 | /// Determines whether custom extension properties defined in the BPMN activity of the external task (e.g. via the Extensions tab in the Camunda modeler) should be included in the response. Default: false. 66 | public bool IncludeExtensionProperties { get; set; } 67 | 68 | public string? ProcessDefinitionId { get; set; } 69 | 70 | public IReadOnlyCollection? ProcessDefinitionIdIn { get; set; } 71 | 72 | public string? ProcessDefinitionKey { get; set; } 73 | 74 | public IReadOnlyCollection? ProcessDefinitionKeyIn { get; set; } 75 | 76 | /// A dictionary used for filtering tasks based on process instance variable values. The property Key of the dictionary represents a process variable name, while the property value represents the process variable value to filter tasks by. 77 | public IReadOnlyDictionary? ProcessVariables { get; set; } 78 | 79 | public bool? WithoutTenantId { get; set; } 80 | 81 | public IReadOnlyCollection? TenantIdIn { get; set; } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Execution/DefaultCamundaWorker.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using Camunda.Worker.Client; 6 | using Microsoft.Extensions.Logging; 7 | using Microsoft.Extensions.Logging.Abstractions; 8 | using Microsoft.Extensions.Options; 9 | 10 | namespace Camunda.Worker.Execution; 11 | 12 | internal sealed class DefaultCamundaWorker : ICamundaWorker 13 | { 14 | private readonly IExternalTaskClient _externalTaskClient; 15 | private readonly IFetchAndLockRequestProvider _fetchAndLockRequestProvider; 16 | private readonly WorkerEvents _workerEvents; 17 | private readonly IServiceProvider _serviceProvider; 18 | private readonly IExternalTaskProcessingService _processingService; 19 | private readonly ILogger _logger; 20 | 21 | public DefaultCamundaWorker( 22 | WorkerIdString workerId, 23 | IExternalTaskClient externalTaskClient, 24 | IFetchAndLockRequestProvider fetchAndLockRequestProvider, 25 | IOptionsMonitor workerEvents, 26 | IServiceProvider serviceProvider, 27 | IExternalTaskProcessingService processingService, 28 | ILogger? logger 29 | ) 30 | { 31 | _externalTaskClient = externalTaskClient; 32 | _fetchAndLockRequestProvider = fetchAndLockRequestProvider; 33 | _workerEvents = workerEvents.Get(workerId.Value); 34 | _serviceProvider = serviceProvider; 35 | _processingService = processingService; 36 | _logger = logger ?? NullLogger.Instance; 37 | } 38 | 39 | public async Task RunAsync(CancellationToken cancellationToken) 40 | { 41 | while (!cancellationToken.IsCancellationRequested) 42 | { 43 | var externalTasks = await SelectAsync(cancellationToken); 44 | 45 | if (externalTasks is { Count: > 0 }) 46 | { 47 | var tasks = new Task[externalTasks.Count]; 48 | var i = 0; 49 | foreach (var externalTask in externalTasks) 50 | { 51 | tasks[i++] = Task.Run( 52 | () => ProcessExternalTaskAsync(externalTask, cancellationToken), 53 | cancellationToken 54 | ); 55 | } 56 | 57 | await Task.WhenAll(tasks); 58 | } 59 | 60 | await _workerEvents.OnAfterProcessingAllTasks(_serviceProvider, cancellationToken); 61 | } 62 | } 63 | 64 | private async Task?> SelectAsync(CancellationToken cancellationToken) 65 | { 66 | await _workerEvents.OnBeforeFetchAndLock(_serviceProvider, cancellationToken); 67 | 68 | try 69 | { 70 | _logger.LogWorker_Waiting(); 71 | var fetchAndLockRequest = _fetchAndLockRequestProvider.GetRequest(); 72 | var externalTasks = await _externalTaskClient.FetchAndLockAsync(fetchAndLockRequest, cancellationToken); 73 | _logger.LogWorker_Locked(externalTasks.Count); 74 | return externalTasks; 75 | } 76 | catch (Exception e) when (!cancellationToken.IsCancellationRequested) 77 | { 78 | _logger.LogWorker_FailedLocking(e.Message, e); 79 | await _workerEvents.OnFailedFetchAndLock(_serviceProvider, cancellationToken); 80 | return null; 81 | } 82 | } 83 | 84 | private async Task ProcessExternalTaskAsync(ExternalTask externalTask, CancellationToken cancellationToken) 85 | { 86 | try 87 | { 88 | await _processingService.ProcessAsync(externalTask, _externalTaskClient, cancellationToken); 89 | } 90 | catch (Exception e) 91 | { 92 | _logger.LogWorker_FailedExecution(externalTask.Id, e); 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Execution/HandlerInvokerTest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using Bogus; 5 | using Camunda.Worker.Client; 6 | using Camunda.Worker.Endpoints; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Moq; 9 | using Xunit; 10 | 11 | namespace Camunda.Worker.Execution; 12 | 13 | public class HandlerInvokerTest : IDisposable 14 | { 15 | private readonly Mock _handlerMock = new(); 16 | private readonly Mock _clientMock = new(); 17 | private readonly Mock _contextMock = new(); 18 | private readonly CancellationTokenSource _processingAborted = new(); 19 | private readonly HandlerInvoker _handlerInvoker; 20 | 21 | public HandlerInvokerTest() 22 | { 23 | var serviceProvider = new ServiceCollection().BuildServiceProvider(); 24 | 25 | var externalTask = new Faker() 26 | .CustomInstantiator(faker => new ExternalTask( 27 | faker.Random.Guid().ToString(), 28 | faker.Random.Word(), 29 | faker.Random.Word()) 30 | ) 31 | .Generate(); 32 | 33 | _contextMock.SetupGet(ctx => ctx.ServiceProvider) 34 | .Returns(serviceProvider); 35 | _contextMock.SetupGet(ctx => ctx.Task) 36 | .Returns(externalTask); 37 | _contextMock.SetupGet(ctx => ctx.Client) 38 | .Returns(_clientMock.Object); 39 | _contextMock.SetupGet(ctx => ctx.ProcessingAborted) 40 | .Returns(_processingAborted.Token); 41 | _handlerInvoker = new HandlerInvoker(_handlerMock.Object, _contextMock.Object); 42 | } 43 | 44 | [Fact] 45 | public async Task ShouldExecuteResultReturnedFromHandler() 46 | { 47 | // Arrange 48 | var resultMock = new Mock(); 49 | 50 | _handlerMock.Setup(handler => handler.HandleAsync(It.IsAny(), It.IsAny())) 51 | .ReturnsAsync(resultMock.Object); 52 | 53 | // Act 54 | await _handlerInvoker.InvokeAsync(); 55 | 56 | // Assert 57 | resultMock.Verify(result => result.ExecuteResultAsync(It.IsAny()), Times.Once()); 58 | } 59 | 60 | [Fact] 61 | public async Task ShouldReportFailureIfHandlerFails() 62 | { 63 | // Arrange 64 | _handlerMock.Setup(handler => handler.HandleAsync(It.IsAny(), It.IsAny())) 65 | .ThrowsAsync(new Exception()); 66 | 67 | // Act 68 | await _handlerInvoker.InvokeAsync(); 69 | 70 | // Assert 71 | _clientMock.Verify( 72 | client => client.ReportFailureAsync(It.IsAny(), It.IsAny(), default), 73 | Times.Once() 74 | ); 75 | } 76 | 77 | [Fact] 78 | public async Task ShouldNotReportFailureIfHandlerCancelled() 79 | { 80 | // Arrange 81 | var taskCompletionSource = new TaskCompletionSource(); 82 | await using var ctReg = _processingAborted.Token.Register(() => 83 | { 84 | taskCompletionSource.TrySetCanceled(_processingAborted.Token); 85 | }); 86 | 87 | _handlerMock.Setup(handler => handler.HandleAsync(It.IsAny(), It.IsAny())) 88 | .Returns(taskCompletionSource.Task); 89 | 90 | // Act 91 | var invokerTask = _handlerInvoker.InvokeAsync(); 92 | await Task.Delay(100); 93 | _processingAborted.Cancel(); 94 | 95 | // Assert 96 | await Assert.ThrowsAsync(() => invokerTask); 97 | } 98 | 99 | public void Dispose() 100 | { 101 | _processingAborted.Dispose(); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | 5 | # User-specific files 6 | *.suo 7 | *.user 8 | *.userosscache 9 | *.sln.docstates 10 | 11 | # User-specific files (MonoDevelop/Xamarin Studio) 12 | *.userprefs 13 | 14 | # Build results 15 | [Dd]ebug/ 16 | [Dd]ebugPublic/ 17 | [Rr]elease/ 18 | [Rr]eleases/ 19 | x64/ 20 | x86/ 21 | build/ 22 | bld/ 23 | [Bb]in/ 24 | [Oo]bj/ 25 | 26 | # Visual Studio 2015 cache/options directory 27 | .vs/ 28 | project.lock.json 29 | 30 | 31 | # MSTest test Results 32 | [Tt]est[Rr]esult*/ 33 | [Bb]uild[Ll]og.* 34 | 35 | # NUNIT 36 | *.VisualState.xml 37 | TestResult.xml 38 | 39 | # Build Results of an ATL Project 40 | [Dd]ebugPS/ 41 | [Rr]eleasePS/ 42 | dlldata.c 43 | 44 | *_i.c 45 | *_p.c 46 | *_i.h 47 | *.ilk 48 | *.meta 49 | *.obj 50 | *.pch 51 | *.pdb 52 | *.pgc 53 | *.pgd 54 | *.rsp 55 | *.sbr 56 | *.tlb 57 | *.tli 58 | *.tlh 59 | *.tmp 60 | *.tmp_proj 61 | *.log 62 | *.vspscc 63 | *.vssscc 64 | .builds 65 | *.pidb 66 | *.svclog 67 | *.scc 68 | 69 | # Chutzpah Test files 70 | _Chutzpah* 71 | 72 | # Visual C++ cache files 73 | ipch/ 74 | *.aps 75 | *.ncb 76 | *.opensdf 77 | *.sdf 78 | *.cachefile 79 | 80 | # Visual Studio profiler 81 | *.psess 82 | *.vsp 83 | *.vspx 84 | 85 | # TFS 2012 Local Workspace 86 | $tf/ 87 | 88 | # Guidance Automation Toolkit 89 | *.gpState 90 | 91 | # ReSharper is a .NET coding add-in 92 | _ReSharper*/ 93 | *.[Rr]e[Ss]harper 94 | *.DotSettings.user 95 | 96 | # JustCode is a .NET coding addin-in 97 | .JustCode 98 | 99 | # TeamCity is a build add-in 100 | _TeamCity* 101 | 102 | # DotCover is a Code Coverage Tool 103 | *.dotCover 104 | 105 | # NCrunch 106 | _NCrunch_* 107 | .*crunch*.local.xml 108 | 109 | # MightyMoose 110 | *.mm.* 111 | AutoTest.Net/ 112 | 113 | # Web workbench (sass) 114 | .sass-cache/ 115 | 116 | # Installshield output folder 117 | [Ee]xpress/ 118 | 119 | # DocProject is a documentation generator add-in 120 | DocProject/buildhelp/ 121 | DocProject/Help/*.HxT 122 | DocProject/Help/*.HxC 123 | DocProject/Help/*.hhc 124 | DocProject/Help/*.hhk 125 | DocProject/Help/*.hhp 126 | DocProject/Help/Html2 127 | DocProject/Help/html 128 | 129 | # Click-Once directory 130 | publish/ 131 | 132 | # Publish Web Output 133 | *.[Pp]ublish.xml 134 | *.azurePubxml 135 | # TODO: Comment the next line if you want to checkin your web deploy settings 136 | # but database connection strings (with potential passwords) will be unencrypted 137 | *.pubxml 138 | *.publishproj 139 | 140 | # NuGet Packages 141 | *.nupkg 142 | # The packages folder can be ignored because of Package Restore 143 | **/packages/* 144 | # except build/, which is used as an MSBuild target. 145 | !**/packages/build/ 146 | # Uncomment if necessary however generally it will be regenerated when needed 147 | #!**/packages/repositories.config 148 | 149 | # Windows Azure Build Output 150 | csx/ 151 | *.build.csdef 152 | 153 | # Windows Store app package directory 154 | AppPackages/ 155 | 156 | # Others 157 | *.[Cc]ache 158 | ClientBin/ 159 | [Ss]tyle[Cc]op.* 160 | ~$* 161 | *~ 162 | *.dbmdl 163 | *.dbproj.schemaview 164 | *.publishsettings 165 | node_modules/ 166 | bower_components/ 167 | 168 | # RIA/Silverlight projects 169 | Generated_Code/ 170 | 171 | # Backup & report files from converting an old project file 172 | # to a newer Visual Studio version. Backup files are not needed, 173 | # because we have git ;-) 174 | _UpgradeReport_Files/ 175 | Backup*/ 176 | UpgradeLog*.XML 177 | UpgradeLog*.htm 178 | 179 | # SQL Server files 180 | *.mdf 181 | *.ldf 182 | 183 | # Business Intelligence projects 184 | *.rdl.data 185 | *.bim.layout 186 | *.bim_*.settings 187 | 188 | # Microsoft Fakes 189 | FakesAssemblies/ 190 | 191 | # Node.js Tools for Visual Studio 192 | .ntvs_analysis.dat 193 | 194 | # Visual Studio 6 build log 195 | *.plg 196 | 197 | # Visual Studio 6 workspace options file 198 | *.opt 199 | docs/_build/ 200 | 201 | # Cake Build Related 202 | tools/ 203 | .dotnet 204 | 205 | # Rider 206 | .idea 207 | 208 | # Visual Studio Code workspace options 209 | .vscode 210 | 211 | # Coverage directory 212 | converage 213 | -------------------------------------------------------------------------------- /Camunda.Worker.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.26124.0 5 | MinimumVisualStudioVersion = 15.0.26124.0 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{0EE6B9DE-6282-47E3-B0AA-79C99861D230}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Camunda.Worker", "src\Camunda.Worker\Camunda.Worker.csproj", "{752742A0-922B-456E-9300-94E54210EFFF}" 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{10FC99E1-8C27-426E-8D4B-EE645ECEF426}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Camunda.Worker.Tests", "test\Camunda.Worker.Tests\Camunda.Worker.Tests.csproj", "{8EFD4F82-2790-4423-986D-33A70049C0CA}" 13 | EndProject 14 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{D397D745-D633-4FE1-B707-697181EE3697}" 15 | EndProject 16 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SampleCamundaWorker", "samples\SampleCamundaWorker\SampleCamundaWorker.csproj", "{78C5DDE7-FE3C-4585-BE22-C8FC6E25B81B}" 17 | EndProject 18 | Global 19 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 20 | Debug|Any CPU = Debug|Any CPU 21 | Debug|x64 = Debug|x64 22 | Debug|x86 = Debug|x86 23 | Release|Any CPU = Release|Any CPU 24 | Release|x64 = Release|x64 25 | Release|x86 = Release|x86 26 | EndGlobalSection 27 | GlobalSection(SolutionProperties) = preSolution 28 | HideSolutionNode = FALSE 29 | EndGlobalSection 30 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 31 | {752742A0-922B-456E-9300-94E54210EFFF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 32 | {752742A0-922B-456E-9300-94E54210EFFF}.Debug|Any CPU.Build.0 = Debug|Any CPU 33 | {752742A0-922B-456E-9300-94E54210EFFF}.Debug|x64.ActiveCfg = Debug|Any CPU 34 | {752742A0-922B-456E-9300-94E54210EFFF}.Debug|x64.Build.0 = Debug|Any CPU 35 | {752742A0-922B-456E-9300-94E54210EFFF}.Debug|x86.ActiveCfg = Debug|Any CPU 36 | {752742A0-922B-456E-9300-94E54210EFFF}.Debug|x86.Build.0 = Debug|Any CPU 37 | {752742A0-922B-456E-9300-94E54210EFFF}.Release|Any CPU.ActiveCfg = Release|Any CPU 38 | {752742A0-922B-456E-9300-94E54210EFFF}.Release|Any CPU.Build.0 = Release|Any CPU 39 | {752742A0-922B-456E-9300-94E54210EFFF}.Release|x64.ActiveCfg = Release|Any CPU 40 | {752742A0-922B-456E-9300-94E54210EFFF}.Release|x64.Build.0 = Release|Any CPU 41 | {752742A0-922B-456E-9300-94E54210EFFF}.Release|x86.ActiveCfg = Release|Any CPU 42 | {752742A0-922B-456E-9300-94E54210EFFF}.Release|x86.Build.0 = Release|Any CPU 43 | {8EFD4F82-2790-4423-986D-33A70049C0CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 44 | {8EFD4F82-2790-4423-986D-33A70049C0CA}.Debug|Any CPU.Build.0 = Debug|Any CPU 45 | {8EFD4F82-2790-4423-986D-33A70049C0CA}.Debug|x64.ActiveCfg = Debug|Any CPU 46 | {8EFD4F82-2790-4423-986D-33A70049C0CA}.Debug|x64.Build.0 = Debug|Any CPU 47 | {8EFD4F82-2790-4423-986D-33A70049C0CA}.Debug|x86.ActiveCfg = Debug|Any CPU 48 | {8EFD4F82-2790-4423-986D-33A70049C0CA}.Debug|x86.Build.0 = Debug|Any CPU 49 | {8EFD4F82-2790-4423-986D-33A70049C0CA}.Release|Any CPU.ActiveCfg = Release|Any CPU 50 | {8EFD4F82-2790-4423-986D-33A70049C0CA}.Release|Any CPU.Build.0 = Release|Any CPU 51 | {8EFD4F82-2790-4423-986D-33A70049C0CA}.Release|x64.ActiveCfg = Release|Any CPU 52 | {8EFD4F82-2790-4423-986D-33A70049C0CA}.Release|x64.Build.0 = Release|Any CPU 53 | {8EFD4F82-2790-4423-986D-33A70049C0CA}.Release|x86.ActiveCfg = Release|Any CPU 54 | {8EFD4F82-2790-4423-986D-33A70049C0CA}.Release|x86.Build.0 = Release|Any CPU 55 | {78C5DDE7-FE3C-4585-BE22-C8FC6E25B81B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 56 | {78C5DDE7-FE3C-4585-BE22-C8FC6E25B81B}.Debug|Any CPU.Build.0 = Debug|Any CPU 57 | {78C5DDE7-FE3C-4585-BE22-C8FC6E25B81B}.Debug|x64.ActiveCfg = Debug|Any CPU 58 | {78C5DDE7-FE3C-4585-BE22-C8FC6E25B81B}.Debug|x64.Build.0 = Debug|Any CPU 59 | {78C5DDE7-FE3C-4585-BE22-C8FC6E25B81B}.Debug|x86.ActiveCfg = Debug|Any CPU 60 | {78C5DDE7-FE3C-4585-BE22-C8FC6E25B81B}.Debug|x86.Build.0 = Debug|Any CPU 61 | {78C5DDE7-FE3C-4585-BE22-C8FC6E25B81B}.Release|Any CPU.ActiveCfg = Release|Any CPU 62 | {78C5DDE7-FE3C-4585-BE22-C8FC6E25B81B}.Release|Any CPU.Build.0 = Release|Any CPU 63 | {78C5DDE7-FE3C-4585-BE22-C8FC6E25B81B}.Release|x64.ActiveCfg = Release|Any CPU 64 | {78C5DDE7-FE3C-4585-BE22-C8FC6E25B81B}.Release|x64.Build.0 = Release|Any CPU 65 | {78C5DDE7-FE3C-4585-BE22-C8FC6E25B81B}.Release|x86.ActiveCfg = Release|Any CPU 66 | {78C5DDE7-FE3C-4585-BE22-C8FC6E25B81B}.Release|x86.Build.0 = Release|Any CPU 67 | EndGlobalSection 68 | GlobalSection(NestedProjects) = preSolution 69 | {752742A0-922B-456E-9300-94E54210EFFF} = {0EE6B9DE-6282-47E3-B0AA-79C99861D230} 70 | {8EFD4F82-2790-4423-986D-33A70049C0CA} = {10FC99E1-8C27-426E-8D4B-EE645ECEF426} 71 | {78C5DDE7-FE3C-4585-BE22-C8FC6E25B81B} = {D397D745-D633-4FE1-B707-697181EE3697} 72 | EndGlobalSection 73 | EndGlobal 74 | -------------------------------------------------------------------------------- /samples/SampleCamundaWorker/say-hello.bpmn: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SequenceFlow_02ymd8v 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | SequenceFlow_02ymd8v 14 | SequenceFlow_1fx92lb 15 | 16 | 17 | 18 | 19 | SequenceFlow_1fx92lb 20 | SequenceFlow_1m9h1n9 21 | SequenceFlow_0shkbev 22 | 23 | 24 | SequenceFlow_0shkbev 25 | 26 | 27 | 28 | SequenceFlow_0hetnmt 29 | 30 | 31 | 32 | 33 | SequenceFlow_0hetnmt 34 | SequenceFlow_1m9h1n9 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/Serialization/VariablesSerializationTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using System.Text.Json; 5 | using System.Text.Json.Nodes; 6 | using System.Text.Json.Serialization; 7 | using System.Xml.Linq; 8 | using Camunda.Worker.Variables; 9 | using Snapshooter; 10 | using Snapshooter.Xunit; 11 | using Xunit; 12 | 13 | namespace Camunda.Worker.Client.Serialization; 14 | 15 | public class VariablesSerializationTests 16 | { 17 | private readonly JsonSerializerOptions _jsonSerializerOptions; 18 | 19 | public VariablesSerializationTests() 20 | { 21 | _jsonSerializerOptions = new JsonSerializerOptions 22 | { 23 | PropertyNamingPolicy = JsonNamingPolicy.CamelCase 24 | } 25 | .Also(opts => 26 | { 27 | opts.Converters.Add(new JsonStringEnumConverter()); 28 | opts.Converters.Add(new VariableJsonConverter()); 29 | opts.Converters.Add(new JsonVariableJsonConverter()); 30 | opts.Converters.Add(new XmlVariableJsonConverter()); 31 | }); 32 | } 33 | 34 | [Theory] 35 | [MemberData(nameof(GetVariables))] 36 | public void ShouldSerialize(string caseDescriptor, VariableBase variable) 37 | { 38 | // Act 39 | var result = JsonSerializer.Serialize(variable, _jsonSerializerOptions); 40 | 41 | // Assert 42 | Snapshot.Match(result, SnapshotNameExtension.Create(caseDescriptor)); 43 | } 44 | 45 | public static IEnumerable GetVariables() 46 | { 47 | yield return new object[] { "Bool1", new BooleanVariable(true) }; 48 | yield return new object[] { "Bool2", new BooleanVariable(false) }; 49 | yield return new object[] { "Double1", new DoubleVariable(double.Epsilon) }; 50 | yield return new object[] { "Double2", new DoubleVariable(0) }; 51 | yield return new object[] { "Double3", new DoubleVariable(Math.PI) }; 52 | yield return new object[] { "Int1", new IntegerVariable(int.MinValue) }; 53 | yield return new object[] { "Int2", new IntegerVariable(int.MaxValue) }; 54 | yield return new object[] { "Short1", new ShortVariable(short.MinValue) }; 55 | yield return new object[] { "Short2", new ShortVariable(short.MaxValue) }; 56 | yield return new object[] { "Long1", new LongVariable(long.MinValue) }; 57 | yield return new object[] { "Long2", new LongVariable(long.MaxValue) }; 58 | yield return new object[] { "String", new StringVariable("\"Hello!\"") }; 59 | yield return new object[] { "Bytes", new BytesVariable(Encoding.UTF8.GetBytes("hello")) }; 60 | yield return new object[] { "Json", new JsonVariable(new JsonObject 61 | { 62 | ["Username"] = "John", 63 | ["Roles"] = new JsonArray 64 | { 65 | "Admin" 66 | } 67 | }) }; 68 | yield return new object[] { "Xml", new XmlVariable(new XDocument( 69 | new XElement("username", "John") 70 | )) }; 71 | } 72 | 73 | [Theory] 74 | [MemberData(nameof(GetVariableValues))] 75 | public void ShouldDeserialize(string caseDescriptor, string variableValue) 76 | { 77 | // Act 78 | var result = JsonSerializer.Deserialize(variableValue, _jsonSerializerOptions); 79 | 80 | // Assert 81 | Snapshot.Match(result, SnapshotNameExtension.Create(caseDescriptor)); 82 | } 83 | 84 | public static IEnumerable GetVariableValues() 85 | { 86 | yield return new object[] { "Bool1", @"{""value"":true,""type"":""Boolean""}" }; 87 | yield return new object[] { "Bool2", @"{""value"":false,""type"":""Boolean""}" }; 88 | yield return new object[] { "Double1", @"{""value"":5E-324,""type"":""Double""}" }; 89 | yield return new object[] { "Double2", @"{""value"":0,""type"":""Double""}" }; 90 | yield return new object[] { "Double3", @"{""value"":3.141592653589793,""type"":""Double""}" }; 91 | yield return new object[] { "Int1", @"{""value"":-2147483648,""type"":""Integer""}" }; 92 | yield return new object[] { "Int2", @"{""value"":2147483647,""type"":""Integer""}" }; 93 | yield return new object[] { "Short1", @"{""value"":-32768,""type"":""Short""}" }; 94 | yield return new object[] { "Short2", @"{""value"":32767,""type"":""Short""}" }; 95 | yield return new object[] { "Long1", @"{""value"":-9223372036854775808,""type"":""Long""}" }; 96 | yield return new object[] { "Long2", @"{""value"":9223372036854775807,""type"":""Long""}" }; 97 | yield return new object[] { "String", @"{""value"":""\u0022Hello!\u0022"",""type"":""String""}" }; 98 | yield return new object[] { "Bytes", @"{""value"":""aGVsbG8="",""type"":""Bytes""}" }; 99 | yield return new object[] { "Json", @"{""value"":""{\u0022Username\u0022:\u0022John\u0022,\u0022Roles\u0022:[\u0022Admin\u0022]}"",""type"":""Json""}" }; 100 | yield return new object[] { "Xml", @"{""value"":""\u003Cusername\u003EJohn\u003C/username\u003E"",""type"":""Xml""}" }; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Camunda.Worker/Client/ExternalTaskClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Net.Http; 5 | using System.Net.Http.Json; 6 | using System.Text.Json; 7 | using System.Text.Json.Serialization; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | using Camunda.Worker.Client.Serialization; 11 | 12 | namespace Camunda.Worker.Client; 13 | 14 | public class ExternalTaskClient : IExternalTaskClient 15 | { 16 | private readonly HttpClient _httpClient; 17 | private readonly JsonSerializerOptions _jsonSerializerOptions; 18 | 19 | public ExternalTaskClient(HttpClient httpClient) 20 | { 21 | _httpClient = Guard.NotNull(httpClient, nameof(httpClient)); 22 | ValidateHttpClient(httpClient); 23 | 24 | _jsonSerializerOptions = new JsonSerializerOptions 25 | { 26 | PropertyNamingPolicy = JsonNamingPolicy.CamelCase 27 | } 28 | .Also(opts => 29 | { 30 | opts.Converters.Add(new JsonStringEnumConverter()); 31 | opts.Converters.Add(new VariableJsonConverter()); 32 | opts.Converters.Add(new JsonVariableJsonConverter()); 33 | opts.Converters.Add(new XmlVariableJsonConverter()); 34 | }); 35 | } 36 | 37 | public ExternalTaskClient(HttpClient httpClient, Action configureJsonOptions) 38 | : this(httpClient) 39 | { 40 | _jsonSerializerOptions = _jsonSerializerOptions 41 | .Also(configureJsonOptions); 42 | } 43 | 44 | [ExcludeFromCodeCoverage] 45 | private static void ValidateHttpClient(HttpClient httpClient) 46 | { 47 | if (httpClient.BaseAddress == null) 48 | { 49 | throw new ArgumentException("BaseAddress must be configured", nameof(httpClient)); 50 | } 51 | } 52 | 53 | public async Task> FetchAndLockAsync( 54 | FetchAndLockRequest request, 55 | CancellationToken cancellationToken = default 56 | ) 57 | { 58 | Guard.NotNull(request, nameof(request)); 59 | 60 | using var response = await SendRequestAsync("/fetchAndLock", request, cancellationToken); 61 | await EnsureSuccessAsync(response); 62 | 63 | var externalTasks = await response.Content 64 | .ReadFromJsonAsync>(_jsonSerializerOptions, cancellationToken); 65 | 66 | return externalTasks ?? new List(); 67 | } 68 | 69 | public async Task CompleteAsync( 70 | string taskId, CompleteRequest request, 71 | CancellationToken cancellationToken = default 72 | ) 73 | { 74 | Guard.NotNull(taskId, nameof(taskId)); 75 | Guard.NotNull(request, nameof(request)); 76 | 77 | using var response = await SendRequestAsync($"/{taskId}/complete", request, cancellationToken); 78 | await EnsureSuccessAsync(response); 79 | } 80 | 81 | public async Task ReportFailureAsync( 82 | string taskId, ReportFailureRequest request, 83 | CancellationToken cancellationToken = default 84 | ) 85 | { 86 | Guard.NotNull(taskId, nameof(taskId)); 87 | Guard.NotNull(request, nameof(request)); 88 | 89 | using var response = await SendRequestAsync($"/{taskId}/failure", request, cancellationToken); 90 | await EnsureSuccessAsync(response); 91 | } 92 | 93 | public async Task ReportBpmnErrorAsync( 94 | string taskId, BpmnErrorRequest request, 95 | CancellationToken cancellationToken = default 96 | ) 97 | { 98 | Guard.NotNull(taskId, nameof(taskId)); 99 | Guard.NotNull(request, nameof(request)); 100 | 101 | using var response = await SendRequestAsync($"/{taskId}/bpmnError", request, cancellationToken); 102 | await EnsureSuccessAsync(response); 103 | } 104 | 105 | public async Task ExtendLockAsync( 106 | string taskId, ExtendLockRequest request, 107 | CancellationToken cancellationToken = default 108 | ) 109 | { 110 | Guard.NotNull(taskId, nameof(taskId)); 111 | Guard.NotNull(request, nameof(request)); 112 | 113 | using var response = await SendRequestAsync($"/{taskId}/extendLock", request, cancellationToken); 114 | await EnsureSuccessAsync(response); 115 | } 116 | 117 | private async Task SendRequestAsync( 118 | string path, T body, CancellationToken cancellationToken 119 | ) where T : notnull 120 | { 121 | var basePath = _httpClient.BaseAddress?.AbsolutePath.TrimEnd('/') ?? string.Empty; 122 | var requestPath = $"{basePath}/external-task/{path.TrimStart('/')}"; 123 | var response = await _httpClient.PostAsJsonAsync(requestPath, body, _jsonSerializerOptions, cancellationToken); 124 | return response; 125 | } 126 | 127 | private async Task EnsureSuccessAsync(HttpResponseMessage response) 128 | { 129 | if (!response.IsSuccessStatusCode && response.IsJson()) 130 | { 131 | var errorResponse = await response.Content 132 | .ReadFromJsonAsync(_jsonSerializerOptions); 133 | response.Content.Dispose(); 134 | throw new ClientException(errorResponse ?? new ErrorResponse(), response.StatusCode); 135 | } 136 | 137 | response.EnsureSuccessStatusCode(); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/CamundaWorkerBuilderExtensionsTest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using Camunda.Worker.Endpoints; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using Moq; 8 | using Xunit; 9 | 10 | namespace Camunda.Worker; 11 | 12 | public class CamundaWorkerBuilderExtensionsTest 13 | { 14 | private readonly IServiceCollection _services = new ServiceCollection(); 15 | private readonly Mock _builderMock = new(); 16 | 17 | public CamundaWorkerBuilderExtensionsTest() 18 | { 19 | _builderMock.SetupGet(builder => builder.Services).Returns(_services); 20 | } 21 | 22 | [Fact] 23 | public void TestAddHandlerWithAttributes() 24 | { 25 | // Arrange 26 | var savedMetadata = new List(); 27 | 28 | _builderMock 29 | .Setup(builder => builder.AddHandler(It.IsAny(), It.IsAny())) 30 | .Callback((ExternalTaskDelegate _, EndpointMetadata metadata) => savedMetadata.Add(metadata)) 31 | .Returns(_builderMock.Object); 32 | 33 | // Act 34 | _builderMock.Object.AddHandler(); 35 | 36 | // Assert 37 | _builderMock.Verify( 38 | builder => builder.AddHandler(It.IsAny(), It.IsAny()), 39 | Times.Once()); 40 | Assert.Contains(_services, d => d.Lifetime == ServiceLifetime.Transient && 41 | d.ServiceType == typeof(HandlerWithTopics)); 42 | 43 | var metadata = Assert.Single(savedMetadata); 44 | Assert.NotNull(metadata.Variables); 45 | var variableName = Assert.Single(metadata.Variables); 46 | Assert.Equal("testVariable", variableName); 47 | } 48 | 49 | [Fact] 50 | public void TestAddHandlerWithAttributesAndMetadataCustomization() 51 | { 52 | // Arrange 53 | var savedMetadata = new List(); 54 | 55 | _builderMock 56 | .Setup(builder => builder.AddHandler(It.IsAny(), It.IsAny())) 57 | .Callback((ExternalTaskDelegate _, EndpointMetadata metadata) => savedMetadata.Add(metadata)) 58 | .Returns(_builderMock.Object); 59 | 60 | // Act 61 | _builderMock.Object.AddHandler(m => 62 | { 63 | m.TenantIds = new[] {"myTenant"}; 64 | }); 65 | 66 | // Assert 67 | _builderMock.Verify( 68 | builder => builder.AddHandler(It.IsAny(), It.IsAny()), 69 | Times.Once()); 70 | Assert.Contains(_services, d => d.Lifetime == ServiceLifetime.Transient && 71 | d.ServiceType == typeof(HandlerWithTopics)); 72 | 73 | var metadata = Assert.Single(savedMetadata); 74 | Assert.NotNull(metadata.Variables); 75 | var variableName = Assert.Single(metadata.Variables); 76 | Assert.Equal("testVariable", variableName); 77 | Assert.Equal("myTenant", metadata.TenantIds![0]); 78 | } 79 | 80 | [Fact] 81 | public void TestAddHandlerWithoutTopic() 82 | { 83 | // Arrange 84 | _builderMock 85 | .Setup(builder => builder.AddHandler(It.IsAny(), It.IsAny())) 86 | .Returns(_builderMock.Object); 87 | 88 | // Act & Assert 89 | Assert.Throws(() => _builderMock.Object.AddHandler()); 90 | } 91 | 92 | [Fact] 93 | public void TestAddHandlerWithAllVariables() 94 | { 95 | // Arrange 96 | var savedMetadata = new List(); 97 | 98 | _builderMock 99 | .Setup(builder => builder.AddHandler(It.IsAny(), It.IsAny())) 100 | .Callback((ExternalTaskDelegate _, EndpointMetadata metadata) => savedMetadata.Add(metadata)) 101 | .Returns(_builderMock.Object); 102 | 103 | // Act 104 | _builderMock.Object.AddHandler(); 105 | 106 | // Assert 107 | _builderMock.Verify( 108 | builder => builder.AddHandler(It.IsAny(), It.IsAny()), 109 | Times.Once()); 110 | Assert.Contains(_services, d => d.Lifetime == ServiceLifetime.Transient && 111 | d.ServiceType == typeof(HandlerWithAllVariables)); 112 | 113 | var metadata = Assert.Single(savedMetadata); 114 | Assert.NotNull(metadata); 115 | Assert.Null(metadata.Variables); 116 | } 117 | 118 | [HandlerTopics("testTopic")] 119 | [HandlerVariables(AllVariables = true)] 120 | private class HandlerWithAllVariables : IExternalTaskHandler 121 | { 122 | public Task HandleAsync(ExternalTask externalTask, CancellationToken cancellationToken) 123 | { 124 | return Task.FromResult(new CompleteResult 125 | { 126 | Variables = externalTask.Variables 127 | }); 128 | } 129 | } 130 | 131 | [HandlerTopics("testTopic_1", "testTopic_1")] 132 | [HandlerVariables("testVariable", LocalVariables = true)] 133 | private class HandlerWithTopics : IExternalTaskHandler 134 | { 135 | public Task HandleAsync(ExternalTask externalTask, CancellationToken cancellationToken) 136 | { 137 | return Task.FromResult(new CompleteResult 138 | { 139 | Variables = externalTask.Variables 140 | }); 141 | } 142 | } 143 | 144 | private class HandlerWithoutTopics : IExternalTaskHandler 145 | { 146 | public Task HandleAsync(ExternalTask externalTask, CancellationToken cancellationToken) 147 | { 148 | return Task.FromResult(new CompleteResult 149 | { 150 | Variables = externalTask.Variables 151 | }); 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Execution/DefaultCamundaWorkerTest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using Bogus; 8 | using Camunda.Worker.Client; 9 | using Microsoft.Extensions.DependencyInjection; 10 | using Microsoft.Extensions.Logging.Abstractions; 11 | using Microsoft.Extensions.Options; 12 | using Moq; 13 | using Xunit; 14 | 15 | namespace Camunda.Worker.Execution; 16 | 17 | public class DefaultCamundaWorkerTest : IDisposable 18 | { 19 | private readonly WorkerIdString _workerId; 20 | private readonly Mock _clientMock = new(); 21 | private readonly Mock _fetchAndLockRequestProviderMock = new(); 22 | private readonly Mock _workerEventsMock = new(); 23 | private readonly Mock _processingServiceMock = new(); 24 | private readonly ServiceProvider _serviceProvider; 25 | private readonly DefaultCamundaWorker _worker; 26 | 27 | public DefaultCamundaWorkerTest() 28 | { 29 | _workerId = new("test-worker"); 30 | 31 | _serviceProvider = new ServiceCollection().BuildServiceProvider(); 32 | 33 | _fetchAndLockRequestProviderMock 34 | .Setup(provider => provider.GetRequest()) 35 | .Returns(new FetchAndLockRequest("test")); 36 | 37 | var workerEventsOptionsMock = new Mock>(); 38 | workerEventsOptionsMock 39 | .Setup(it => it.Get(_workerId.Value)) 40 | .Returns(new WorkerEvents 41 | { 42 | OnFailedFetchAndLock = _workerEventsMock.Object.OnFailedFetchAndLock, 43 | OnAfterProcessingAllTasks = _workerEventsMock.Object.OnAfterProcessingAllTasks 44 | }); 45 | 46 | _worker = new DefaultCamundaWorker( 47 | _workerId, 48 | _clientMock.Object, 49 | _fetchAndLockRequestProviderMock.Object, 50 | workerEventsOptionsMock.Object, 51 | _serviceProvider, 52 | _processingServiceMock.Object, 53 | NullLogger.Instance 54 | ); 55 | } 56 | 57 | public void Dispose() 58 | { 59 | _serviceProvider.Dispose(); 60 | } 61 | 62 | [Theory] 63 | [InlineData(0)] 64 | [InlineData(1)] 65 | [InlineData(10)] 66 | public async Task TestRun(int numberOfExternalTasks) 67 | { 68 | // Arrange 69 | var externalTasks = new Faker() 70 | .CustomInstantiator(faker => new ExternalTask( 71 | faker.Random.Guid().ToString(), 72 | faker.Random.Word(), 73 | faker.Random.Word()) 74 | ) 75 | .GenerateLazy(numberOfExternalTasks) 76 | .ToList(); 77 | 78 | var cts = new CancellationTokenSource(); 79 | 80 | _workerEventsMock 81 | .Setup(e => e.OnAfterProcessingAllTasks(It.IsAny(), It.IsAny())) 82 | .Callback(cts.Cancel) 83 | .Returns(Task.CompletedTask); 84 | 85 | _clientMock 86 | .Setup(client => client.FetchAndLockAsync(It.IsAny(), It.IsAny())) 87 | .ReturnsAsync(externalTasks) 88 | .Verifiable(); 89 | 90 | var processedTasks = new ConcurrentBag(); 91 | 92 | _processingServiceMock 93 | .Setup(service => service.ProcessAsync(It.IsAny(), _clientMock.Object, It.IsAny())) 94 | .Callback((ExternalTask externalTask, IExternalTaskClient _, CancellationToken _) => 95 | { 96 | processedTasks.Add(externalTask); 97 | }) 98 | .Returns(Task.CompletedTask); 99 | 100 | // Act 101 | await _worker.RunAsync(cts.Token); 102 | 103 | // Assert 104 | _processingServiceMock.Verify( 105 | service => service.ProcessAsync(It.IsAny(), _clientMock.Object, It.IsAny()), 106 | Times.Exactly(numberOfExternalTasks) 107 | ); 108 | Assert.Equal( 109 | externalTasks.ToHashSet(new ExternalTaskComparer()), 110 | processedTasks.ToHashSet(new ExternalTaskComparer()) 111 | ); 112 | } 113 | 114 | [Fact] 115 | public async Task TestCancelledSelection() 116 | { 117 | var cts = new CancellationTokenSource(); 118 | var tcs = new TaskCompletionSource>(); 119 | 120 | await using var reg = cts.Token.Register(() => tcs.SetCanceled()); 121 | 122 | _clientMock 123 | .Setup(client => 124 | client.FetchAndLockAsync(It.IsAny(), It.IsAny())) 125 | .Returns(tcs.Task); 126 | 127 | var resultTask = _worker.RunAsync(cts.Token); 128 | 129 | cts.Cancel(); 130 | 131 | await Assert.ThrowsAsync(() => resultTask); 132 | _clientMock.VerifyAll(); 133 | } 134 | 135 | [Fact] 136 | public async Task TestFailedFetchAndLock() 137 | { 138 | // Arrange 139 | var cts = new CancellationTokenSource(); 140 | 141 | _workerEventsMock 142 | .Setup(e => e.OnAfterProcessingAllTasks(It.IsAny(), It.IsAny())) 143 | .Callback(cts.Cancel) 144 | .Returns(Task.CompletedTask); 145 | 146 | _clientMock 147 | .Setup(client => client.FetchAndLockAsync(It.IsAny(), It.IsAny())) 148 | .ThrowsAsync(new Exception("Some exception")) 149 | .Verifiable(); 150 | 151 | // Act 152 | await _worker.RunAsync(cts.Token); 153 | 154 | // Assert 155 | _workerEventsMock.Verify(e => e.OnFailedFetchAndLock(It.IsAny(), It.IsAny()), Times.Once()); 156 | } 157 | 158 | public interface IWorkerEvents 159 | { 160 | public Task OnAfterProcessingAllTasks(IServiceProvider provider, CancellationToken ct); 161 | 162 | public Task OnFailedFetchAndLock(IServiceProvider provider, CancellationToken ct); 163 | } 164 | 165 | private class ExternalTaskComparer : IEqualityComparer 166 | { 167 | public bool Equals(ExternalTask? x, ExternalTask? y) 168 | { 169 | if (ReferenceEquals(x, y)) return true; 170 | if (ReferenceEquals(x, null)) return false; 171 | if (ReferenceEquals(y, null)) return false; 172 | if (x.GetType() != y.GetType()) return false; 173 | return x.Id == y.Id; 174 | } 175 | 176 | public int GetHashCode(ExternalTask obj) 177 | { 178 | return obj.Id.GetHashCode(); 179 | } 180 | } 181 | 182 | public abstract class FakeExternalTaskProcessingService : IExternalTaskProcessingService 183 | { 184 | public abstract Task ProcessAsync( 185 | ExternalTask externalTask, 186 | IExternalTaskClient externalTaskClient, 187 | CancellationToken cancellationToken 188 | ); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /test/Camunda.Worker.Tests/Client/ExternalTaskClientTest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Net; 4 | using System.Net.Http; 5 | using System.Text; 6 | using System.Text.Encodings.Web; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | using Camunda.Worker.Variables; 10 | using RichardSzalay.MockHttp; 11 | using Xunit; 12 | 13 | namespace Camunda.Worker.Client; 14 | 15 | public class ExternalTaskClientTest : IDisposable 16 | { 17 | private readonly MockHttpMessageHandler _handlerMock = new(); 18 | private ExternalTaskClient _client; 19 | private HttpClient _httpClient; 20 | 21 | public ExternalTaskClientTest() 22 | { 23 | _httpClient = new HttpClient(_handlerMock) 24 | { 25 | BaseAddress = new Uri("http://test/api") 26 | }; 27 | 28 | _client = new ExternalTaskClient(_httpClient); 29 | } 30 | 31 | public void Dispose() 32 | { 33 | _handlerMock.Dispose(); 34 | } 35 | 36 | [Fact] 37 | public async Task TestFetchAndLock() 38 | { 39 | _handlerMock.Expect(HttpMethod.Post, "http://test/api/external-task/fetchAndLock") 40 | .Respond("application/json", @"[ 41 | { 42 | ""id"": ""testTask"", 43 | ""workerId"": ""testWorker"", 44 | ""topicName"": ""testTopic"", 45 | ""processDefinitionId"": ""testDefinitionId"", 46 | ""processDefinitionKey"": ""testDefinitionKey"", 47 | ""activityId"": ""anActivityId"", 48 | ""activityInstanceId"": ""anActivityInstanceId"", 49 | ""errorMessage"": ""anErrorMessage"", 50 | ""errorDetails"": ""anErrorDetails"", 51 | ""executionId"": ""anExecutionId"", 52 | ""tenantId"": null, 53 | ""retries"": 3, 54 | ""priority"": 4, 55 | ""variables"": { 56 | ""TEST"": { 57 | ""value"": ""testString"", 58 | ""type"": ""String"" 59 | } 60 | } 61 | } 62 | ]"); 63 | 64 | var request = new FetchAndLockRequest("testWorker", 10) 65 | { 66 | AsyncResponseTimeout = 10_000, 67 | Topics = new[] 68 | { 69 | new FetchAndLockRequest.Topic("testTopic", 10_000) 70 | } 71 | }; 72 | 73 | var externalTasks = await _client.FetchAndLockAsync(request, CancellationToken.None); 74 | 75 | _handlerMock.VerifyNoOutstandingExpectation(); 76 | var firstTask = Assert.Single(externalTasks); 77 | Assert.NotNull(firstTask); 78 | } 79 | 80 | [Fact] 81 | public async Task TestComplete() 82 | { 83 | _handlerMock.Expect(HttpMethod.Post, "http://test/api/external-task/testTask/complete") 84 | .Respond(HttpStatusCode.NoContent); 85 | 86 | var request = new CompleteRequest("testWorker") 87 | { 88 | Variables = new Dictionary 89 | { 90 | ["TEST"] = new StringVariable("testString") 91 | } 92 | }; 93 | 94 | await _client.CompleteAsync("testTask", request, CancellationToken.None); 95 | 96 | _handlerMock.VerifyNoOutstandingExpectation(); 97 | } 98 | 99 | [Fact] 100 | public async Task TestCompleteWithCustomJsonOptions() 101 | { 102 | _client = new ExternalTaskClient(_httpClient, opt => 103 | { 104 | opt.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping; 105 | }); 106 | 107 | var entity = new 108 | { 109 | Description = "The \"Спутник-1\" satellite" 110 | }; 111 | 112 | var requestContent = @"{""workerId"":""testWorker"",""variables"":{""TEST"":{""value"":" + 113 | @"""{\""Description\"":\""The \\\""Спутник-1\\\"" satellite\""}""," + 114 | @"""type"":""Json""}},""localVariables"":null}"; 115 | 116 | _handlerMock.Expect(HttpMethod.Post, "http://test/api/external-task/testTask/complete") 117 | .WithContent(requestContent) 118 | .Respond(HttpStatusCode.NoContent); 119 | 120 | var request = new CompleteRequest("testWorker") 121 | { 122 | Variables = new Dictionary 123 | { 124 | ["TEST"] = JsonVariable.Create(entity) 125 | } 126 | }; 127 | 128 | await _client.CompleteAsync("testTask", request, CancellationToken.None); 129 | 130 | _handlerMock.VerifyNoOutstandingExpectation(); 131 | } 132 | 133 | [Fact] 134 | public async Task TestReportFailure() 135 | { 136 | _handlerMock.Expect(HttpMethod.Post, "http://test/api/external-task/testTask/failure") 137 | .Respond(HttpStatusCode.NoContent); 138 | 139 | var request = new ReportFailureRequest("testWorker") 140 | { 141 | ErrorMessage = "Error", 142 | ErrorDetails = "Details" 143 | }; 144 | 145 | await _client.ReportFailureAsync("testTask", request, CancellationToken.None); 146 | 147 | _handlerMock.VerifyNoOutstandingExpectation(); 148 | } 149 | 150 | [Fact] 151 | public async Task TestReportBpmnError() 152 | { 153 | _handlerMock.Expect(HttpMethod.Post, "http://test/api/external-task/testTask/bpmnError") 154 | .Respond(HttpStatusCode.NoContent); 155 | 156 | var request = new BpmnErrorRequest("testWorker", "testCode", "Error") 157 | { 158 | Variables = new Dictionary() 159 | }; 160 | 161 | await _client.ReportBpmnErrorAsync("testTask", request, CancellationToken.None); 162 | 163 | _handlerMock.VerifyNoOutstandingExpectation(); 164 | } 165 | 166 | [Fact] 167 | public async Task TestExtendLock() 168 | { 169 | _handlerMock.Expect(HttpMethod.Post, "http://test/api/external-task/testTask/extendLock") 170 | .Respond(HttpStatusCode.NoContent); 171 | 172 | var request = new ExtendLockRequest("testWorker", 10_000); 173 | 174 | await _client.ExtendLockAsync("testTask", request, CancellationToken.None); 175 | 176 | _handlerMock.VerifyNoOutstandingExpectation(); 177 | } 178 | 179 | [Theory] 180 | [MemberData(nameof(GetApiActions))] 181 | public async Task TestThrowsHttpRequestException(Func action) 182 | { 183 | _handlerMock.When(HttpMethod.Post, "http://test/api/external-task/fetchAndLock") 184 | .Respond(HttpStatusCode.InternalServerError, new StringContent("")); 185 | _handlerMock.When(HttpMethod.Post, "http://test/api/external-task/taskId/extendLock") 186 | .Respond(HttpStatusCode.InternalServerError, new StringContent("")); 187 | _handlerMock.When(HttpMethod.Post, "http://test/api/external-task/taskId/complete") 188 | .Respond(HttpStatusCode.InternalServerError, new StringContent("")); 189 | _handlerMock.When(HttpMethod.Post, "http://test/api/external-task/taskId/failure") 190 | .Respond(HttpStatusCode.InternalServerError, new StringContent("")); 191 | _handlerMock.When(HttpMethod.Post, "http://test/api/external-task/taskId/bpmnError") 192 | .Respond(HttpStatusCode.InternalServerError, new StringContent("")); 193 | 194 | await Assert.ThrowsAsync(() => action(_client)); 195 | } 196 | 197 | [Theory] 198 | [MemberData(nameof(GetApiActions))] 199 | public async Task TestThrowsClientException(Func action) 200 | { 201 | var errorContent = new StringContent(@" 202 | { 203 | ""type"": ""An error type"", 204 | ""message"": ""An error message"" 205 | } 206 | ", Encoding.UTF8, "application/json"); 207 | 208 | _handlerMock.When(HttpMethod.Post, "http://test/api/external-task/fetchAndLock") 209 | .Respond(HttpStatusCode.InternalServerError, errorContent); 210 | _handlerMock.When(HttpMethod.Post, "http://test/api/external-task/taskId/extendLock") 211 | .Respond(HttpStatusCode.InternalServerError, errorContent); 212 | _handlerMock.When(HttpMethod.Post, "http://test/api/external-task/taskId/complete") 213 | .Respond(HttpStatusCode.InternalServerError, errorContent); 214 | _handlerMock.When(HttpMethod.Post, "http://test/api/external-task/taskId/failure") 215 | .Respond(HttpStatusCode.InternalServerError, errorContent); 216 | _handlerMock.When(HttpMethod.Post, "http://test/api/external-task/taskId/bpmnError") 217 | .Respond(HttpStatusCode.InternalServerError, errorContent); 218 | 219 | var clientException = await Assert.ThrowsAsync(() => action(_client)); 220 | Assert.Equal(HttpStatusCode.InternalServerError, clientException.StatusCode); 221 | Assert.Equal("An error type", clientException.ErrorType); 222 | Assert.Equal("An error message", clientException.ErrorMessage); 223 | } 224 | 225 | public static IEnumerable GetApiActions() 226 | { 227 | var fetchAndLockRequest = new FetchAndLockRequest("testWorker", 10); 228 | yield return new object[] 229 | { 230 | new Func(c => c.FetchAndLockAsync(fetchAndLockRequest)) 231 | }; 232 | 233 | var extendLockRequest = new ExtendLockRequest("testWorker", 10_000); 234 | yield return new object[] 235 | { 236 | new Func(c => c.ExtendLockAsync("taskId", extendLockRequest)) 237 | }; 238 | 239 | var completeRequest = new CompleteRequest("testWorker"); 240 | yield return new object[] 241 | { 242 | new Func(c => c.CompleteAsync("taskId", completeRequest)) 243 | }; 244 | 245 | var reportFailureRequest = new ReportFailureRequest("test"); 246 | yield return new object[] 247 | { 248 | new Func(c => c.ReportFailureAsync("taskId", reportFailureRequest)) 249 | }; 250 | 251 | var bpmnErrorRequest = new BpmnErrorRequest("test", "test", "test"); 252 | yield return new object[] 253 | { 254 | new Func(c => c.ReportBpmnErrorAsync("taskId", bpmnErrorRequest)) 255 | }; 256 | } 257 | 258 | private ExternalTaskClient MakeClient() 259 | { 260 | return new ExternalTaskClient( 261 | new HttpClient(_handlerMock) 262 | { 263 | BaseAddress = new Uri("http://test/api") 264 | } 265 | ); 266 | } 267 | } 268 | --------------------------------------------------------------------------------