Giter VIP home page Giter VIP logo

zeebe-client-csharp-accelerator's Introduction

BUILD ANALYZE Compatible with: Camunda Platform 8

Bootstrap Accelerator for the C# Zeebe client

This project is an extension of the C# Zeebe client project. Zeebe Workers are automatically recognized and bootstrapped via a .Net HostedService.

Read the Zeebe documentation for more information about the Zeebe project.

The basic idea and implementation for this came from https://github.com/camunda-community-hub/zeebe-client-csharp-bootstrap. We loved the idea, but had in some parts our own preferences for defaults, behaviour and separation of concerns. So this is our version of a good Bootstrap Extension for the C# Zeebe Client. Credits for the base work still belong to https://github.com/arjangeertsema.

Requirements

Since version 2.1.3:

For older .NET versions please use the 1.x.x release of this extension based on Zeebe C# client 1.3.0 release.

How to use

The Zeebe C# client bootstrap extension is available via nuget (https://www.nuget.org/packages/zb-client-accelerator/).

Recommendation: a complete sample project using this extension can be found in examples.

Quick start

All classes which implement IZeebeWorker, IAsyncZeebeWorker, IZeebeWorkerWithResult or IAsyncZeebeWorkerWithResult are automatically added to the service collection and autowired to Zeebe when you register this bootstrap project with the IServiceCollection.BootstrapZeebe() extension method.

More power is provided by using global::Zeebe.Client.Accelerator.Extensions; which provides you with further extensions for IHost, IZeebeClient etc. in order to deploy processes or create one time message receivers.

Bootstrap Zeebe

The BootstrapZeebe method has two parameters:

  1. ZeebeBootstrapOptions via configuration, action delegate or both.
  2. An array with assemblies which will be scanned for job handlers.
ConfigureServices((hostContext, services) => {
    services.BootstrapZeebe(
        hostContext.Configuration.GetSection("ZeebeConfiguration"),
        this.GetType().Assembly
    );
})

Example Web Application:

// Start building my WebApplication
var builder = WebApplication.CreateBuilder(args);

// Bootstrap Zeebe Integration
builder.Services.BootstrapZeebe(
    builder.Configuration.GetSection("ZeebeConfiguration"),
    typeof(Program).Assembly);

The configuration will e.g. look as follows:

{
  "ZeebeConfiguration": {
    "Client": {
      "GatewayAddress": "127.0.0.1:26500"
    },
    "Worker": {
      "MaxJobsActive": 5,
      "HandlerThreads": 3,
      "TimeoutInMilliseconds": 500,
      "PollIntervalInMilliseconds": 50,
      "PollingTimeoutInMilliseconds": 1000,
      "RetryTimeoutInMilliseconds": 1000
    }
  },
}

The GatewayAddress attribute can be set as well via standard environment variable ZEEBE_ADDRESS (since 1.0.2).

Configuring Camunda Platform 8 SaaS Connection

Since 1.0.2

Connections to the Camunda SaaS can be easily configured. Upon creating a new Zeebe API Client in the Cloud Console select the "Env Vars" section for your credentials and memorize all ZEEBE_* environment variables. You will get something like the following:

export ZEEBE_ADDRESS='a1b2c3dd-12ab-3c4d-ab1b-ab1c23abcc12.bru-2.zeebe.camunda.io:443'
export ZEEBE_CLIENT_ID='ABcDE~a0bCD1eFGH1aEF5G.6HI_abCd0'
export ZEEBE_CLIENT_SECRET='ABCDeFgHi1J0KLMnO0PQrOstUVWXyZAbCdeFGh2IjkLmnO-pqrstUVw0xyzab.cd'
export ZEEBE_AUTHORIZATION_SERVER_URL='https://login.cloud.camunda.io/oauth/token'
export ZEEBE_TOKEN_AUDIENCE='zeebe.camunda.io'

You now have 2 options. You can either set exactly these ZEEBE_* environment variables and you are done. Of course you can alternatively manage these settings in the appsettings.json file:

{
  "ZeebeConfiguration": {
    "Client": {
      "GatewayAddress": "a1b2c3dd-12ab-3c4d-ab1b-ab1c23abcc12.bru-2.zeebe.camunda.io:443",
      "Cloud": {
        "ClientId": "ABcDE~a0bCD1eFGH1aEF5G.6HI_abCd0",
        "ClientSecret": "ABCDeFgHi1J0KLMnO0PQrOstUVWXyZAbCdeFGh2IjkLmnO-pqrstUVw0xyzab.cd",
        "AuthorizationServerUrl": "https://login.cloud.camunda.io/oauth/token",
        "TokenAudience": "zeebe.camunda.io"
      }
    }

Further rules:

  • Environment variables have precedence over appsettings.json.
  • AutorizationServerUrl and TokenAudience have the shown values as default values. Thus they are optional settings.

Troubleshouting

If you get DNS errors from the gRPC layer (e.g. "DNS resolution failed for service"), you might need to set the following environment variable:

export GRPC_DNS_RESOLVER=native

Further documentation is available under gRPC environment variables.

Other Transport layer options

The implementation is based on the Zeebe C# Client and therefore has some more options available:

{
  "ZeebeConfiguration": {
    "Client": {
      "GatewayAddress": "my-zeebe-gateway:26500",
      "KeepAliveInMilliSeconds": ...
      "TransportEncryption": {
        "RootCertificatePath": "...",
        "AccessToken": "..."
      }

Transport encryption settings can as well be provided using environment variables ZEEBE_ROOT_CERTIFICATE_PATH, ZEEBE_ACCESS_TOKEN.

Providing your own AccessTokenSupplier

Since 2.1.8

You are able to provide your own IAccessTokenSupplier implementation - e.g. using Duende.AccessTokenManagement - simply by registering your implementation in DI before bootstrapping this extension:

// Register custom AccessTokenSupplier
builder.Services.AddSingleton<IAccessTokenSupplier, MyCustomTokenSupplier>();

// Bootstrap Zeebe Integration
builder.Services.BootstrapZeebe(
    builder.Configuration.GetSection("ZeebeConfiguration"),
    typeof(Program).Assembly);

For more detailed info on this topic see the following zeebe-client-csharp/discussions

Deploy Processes

If we want to deploy some processes right before the final startup of our application we create a deployment using the extension for IHost or IServiceProvider as follows:

var app = builder.Build();
...
// Deploy all process resources
app.CreateZeebeDeployment()
    .UsingDirectory("Resources")
    .AddResource("insurance_application.bpmn")
    .AddResource("document_request.bpmn")
    .AddResource("risk_check.dmn")
    .Deploy();

// Now run the application
app.Run();

Zeebe Workers

A Zeebe Worker is an implementation of IZeebeWorker, IAsyncZeebeWorker, IZeebeWorkerWithResult or IAsyncZeebeWorkerWithResult. Zeebe Workers are automatically added to the DI container, therefore you can use dependency injection inside. The default worker configuration can be overwritten with AbstractWorkerAttribute implementations, see attributes for more information.

[JobType("doSomeWork")]
public class SomeWorker : IAsyncZeebeWorker
{
    private readonly MyApiService _myApiService;

    public SimpleJobHandler(MyApiService myApiService)
    {
        _myApiService = myApiService;
    }

    /// <summary>
    /// Handles the job "doSomeWork".
    /// </summary>
    /// <param name="job">the Zeebe job</param>
    /// <param name="cancellationToken">cancellation token</param>
    public async Task HandleJob(ZeebeJob job, CancellationToken cancellationToken)
    {  
        // execute business service etc.
        await _myApiService.DoSomethingAsync(cancellationToken);
    }
}

Of course you are able to access process variables and return a result. E.g.:

[JobType("doAwesomeWork")]
public class AwesomeWorker : IAsyncZeebeWorker<SimpleJobPayload, SimpleResponse>
{
    ...

    public async Task<SimpleResponse> HandleJob(ZeebeJob<SimpleJobPayload> job, CancellationToken cancellationToken)
    {  
        // get variables as declared (SimpleJobPayload)
        var variables = job.getVariables();

        // execute business service etc.
        var result = await _myApiService.DoSomethingAsync(variables.CustomerNo, cancellationToken);
        return new SimpleResponse(result);
    }

    class SimpleJobPayload
    {
        public string CustomerNo { get; set; }
    }
}

The above code will fetch exactly the variables defined as attributes in SimpleJobPaylad from the process.

And there are more options, including the option to access custom headers configured in the process model:

[JobType("doComplexWork")]
public class ComplexWorker : IAsyncZeebeWorker
{
    ...

    public async Task HandleJob(ZeebeJob job, CancellationToken cancellationToken)
    {  
        // get all variables (and deserialize to a given type)
        ProcessVariables variables = job.getVariables<ProcessVariables>();
        // get custom headers (and deserialize to a given type)
        MyCustomHeaders headers = job.getCustomHeaders<MyCustomHeaders>();

        // execute business service etc.
        await _myApiService.DoSomethingComplex(variables.Customer, headers.SomeConfiguration, cancellationToken);
        ...
    }

    class ProcessVariables
    {
        public string? BusinessKey { get; set; }

        public CustomerData Customer { get; set; }

        public string? AccountName { get; set; }

        ...
    }

    class MyCustomHeaders
    {
        public string SomeConfiguration { get; set; }
    }
}

The following table gives you an overview of the available options:

Interface Description Fetched Variables
IAsyncZeebeWorker Asynchronous worker without specific input and no response Default is to fetch all process variables. Use FetchVariables attribute for restictions.
IAsyncZeebeWorker<TInput> Asynchronous worker with specific input and no response Fetches exactly the variables defined as attributes in TInput.
IAsyncZeebeWorker<TInput, TResponse> Asynchronous worker with specific input and specific response Fetches exactly the variables defined as attributes in TInput.
IAsyncZeebeWorkerWithResult<TResponse> Asynchronous worker without specific input but a specific response Default is to fetch all process variables. Use FetchVariables attribute for restrictions.
IZeebeWorker Synchronous worker without specific input and no response Default is to fetch all process variables. Use FetchVariables attribute for restictions.
IZeebeWorker<TInput> Synchronous worker with specific input and no response Fetches exactly the variables defined as attributes in TInput.
IZeebeWorker<TInput, TResponse> Synchronous worker with specific input and specific response Fetches exactly the variables defined as attributes in TInput.
IZeebeWorkerWithResult<TResponse> Synchronous worker without specific input but a specific response Default is to fetch all process variables. Use FetchVariables attribute for restrictions.

If you like to explicitely restrict the variables fetched from Zeebe, you have the following additional option:

[JobType("doComplexWork")]
[FetchVariables("businessKey", "applicantName")]
public class SimpleWorker : IAsyncZeebeWorker
{
   ...
}

In case you do not want to fetch any variables at all from Zeebe, use [FetchVariables(none: true)]:

[JobType("doSimpleWork")]
[FetchVariables(none: true)]
class SimpleWorker : IZeebeWorker
{
   ...
}

A handled job has three outcomes:

  1. The job has been handled without exceptions: this will automaticly result in a JobCompletedCommand beeing send to the broker. The optional TResponse is automaticly serialized and added to the JobCompletedCommand.
  2. A BpmnErrorException has been thrown while handling the job: this will automaticly result in a ThrowErrorCommand beeing send to the broker triggering Error Boundary Events in the process.
  3. Any other unexpected exception will automatically result in a FailCommand beeing send to the broker including message details and reducing the number of retries;

Custom attribute naming

Since 1.1.0

This extension uses CamelCase as default naming policy. In order to customize serialization and deserialization the standard JsonPropertyNameand JsonIgnore attributes are fully supported:

public class MyJobVariables
{
    [JsonPropertyName("MY_AmountName")]
    public long Amount { get; set; }

    [JsonIgnore]
    public string ToBeIgnored { get; set; }
}

Manual job completion

Since 2.1.0

For use cases where autocompletion is not to be used, the [AutoComplete(false)] attribute is at your disposal:

[AutoComplete(false)]
public class ManualJobHandler : IAsyncZeebeWorker
{
    public async Task HandleJob(ZeebeJob job, CancellationToken cancellationToken)
    {
        // do something ...

        // complete job manually
        await job.GetClient().NewCompleteJobCommand(job.Key).Send(token: cancellationToken);
    }
}

Please be aware, that uncatched exceptions still lead to sending fail commands (or error commands in case of BpmnErrorException). It's the responsibility of the worker implementation to catch and handle all exceptions if a different behaviour is intended.

Dynamic message receiver

See Example for synchronous responses from processes for a description of the scenario.

You can create a one time job handler for receiving a message for a dynamic job type "received_" + number as follows:

try
{
    string jsonContent = _zeebeClient.ReceiveMessage("received_" + number, TimeSpan.FromSeconds(5), "someVariable1", "someVariable2");
    ...
} catch (MessageTimeoutException)
{
    // nothing received
    ...
}

Of course it is possible to use a typed response, which will automatically fetch and deserialize all variables defined as attributes in the given type:

MyVariables typedContent = _zeebeClient.ReceiveMessage<MyVariables>("received_" + number, TimeSpan.FromSeconds(3));

Simply waiting without receiving any variables:

bool messageReceived = _zeebeClient.ReceiveMessage("received_" + number, TimeSpan.FromSeconds(3));

The one time job handler will be destroyed after ReceiveMessage returns.

Hints

  1. By default the workers are added to de DI container with a Transient service lifetime. This can be overriden by adding the ServiceLifetimeAttribute to the worker, see attributes for more information.
  2. By default the ZeebeVariablesSerializer is registered as the implementation for IZeebeVariablesSerializer which uses System.Text.Json.JsonSerializer. Serialization / Deserialization always uses CamelCase as naming policy! JsonPropertyName and JsonIgnore attributes are supported, so that you still have the option to customize your attribute naming.
  3. The default job type of a worker is the class name of the worker. This can be overriden by adding the JobTypeAttribute to the worker, e.g. [JobType("myJobName")].

How to build

Run dotnet build Zeebe.Client.Accelerator.sln

How to test

Run dotnet test Zeebe.Client.Accelerator.sln

zeebe-client-csharp-accelerator's People

Contributors

arjangeertsema avatar arjentfe avatar celanthe avatar dependabot[bot] avatar eterne5 avatar falko avatar jejuni avatar mmoelleraccso avatar renovate[bot] avatar taneltinits avatar tarmopr avatar vonderbeck avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

zeebe-client-csharp-accelerator's Issues

Support for Custom Attribute Naming with Non-camelCase Variables

Is your feature request related to a problem? Please describe.

I am currently using the Zeebe.Client.Accelerator package for my C# project,. The package automatically deserializes the TInput object into an array of fetch variables. However, I've encountered an issue with variable naming conventions. While the official camunda documentation allows the usage of camelCase, PascalCase, and snake_case for variable names, the Zeebe.Client.Accelerator package only supports camelCase. This limitation is causing problems in our integration with camunda, as we need the flexibility to use custom variable names that may not follow the camelCase convention.

Describe the solution you'd like

To address this limitation and enhance the package's functionality, I propose the implementation of a new custom attribute in C#. The custom attribute should be used to decorate C# properties and provide the necessary metadata for mapping these properties to the desired Zeebe variable names. With this attribute, users can specify the exact variable names they want to use, regardless of the variable naming convention.

Expected Behavior:

The package should support custom attribute mapping for properties to allow users to define custom variable names when deserializing TInput into the array of fetch variables. This custom attribute should enable us to map C# property names to custom Zeebe variable names in a straightforward manner.

Example Usage:

public class MyWorkflowInput
{
    [ZeebeVariableName("OrderId")] // Using the custom attribute to map to Zeebe variable "OrderId"		
    public string OrderId { get; set; }

    [ZeebeVariableName("ItemList")] // Using the custom attribute to map to Zeebe variable "ItemList"
    public List<string> ItemList { get; set; }

    // Additional properties and their mappings as needed...
}

Addional context

existing mapping to fetchVariables: JobHandlerInfoProvider.GetFetchVariablesFromJobState(Type jobType) - Line: 171

Unable to use abstract classes

Hi everyone,

I'm trying to use abstract class on top on all my IAsyncZeebeWorkers classes (derivative classes). I faced that BootstrapZeebe scan in assemblies and try to register using DeclaringType instead of ReflectedType.

If ReflectedType is used in AddZeebeJobHandlers method (ServiceCollectionExtensions) and in HandleJob (ZeebeJobHandler.cs) all goes fine.

Thanks.

Lack of handler threads configuration when creating a new job worker

Hi @arjangeertsema. I'm developing a POC with camunda platform and found your fork very useful for simplifying the overall experience configuring the Zeebe client, thanks for that. I'm trying to replicate the first implementation of my POC using your library but I noticed the lack of the .HandlerThreads() implementation when creating the JobWorker, which handles how many threads could be used by the worker, any particular reason for not including it? Thanks.

I would like to provide the zb-client-sharp my custom AccessTokenSupplier

Problem

Right now the zb-client-sharp AccessTokenSupplier is quite simple http client that fetches tokens and caches it to file. With high load and multithreaded usage its not performing well and is causing file locks and due to that errors in logs.

Solution

With zb-client-sharp there is possibility to provide custom token supplier in the client builder. The builder has to implement IAccessTokenSupplier interface which is included in the zb-client-sharp.

Idea is in the accelerator setup to check if there is a service implementing IAccessTokenSupplier registered in DI and if its there, it will be provided to the zb-client builder.
Possible fix is done in the PR: #31

Describe alternatives you've considered

Alternate solution would be to update zb-client-sharp token supplier code - it would need more advanced token provider. But as there are already decent libraries that have those so no reason to build it in there. (ie Duende.AccessTokenManagement

Additional context

Explanation how to use custom provider is also under the zb-client-sharp discussions: camunda-community-hub/zeebe-client-csharp#666

Zeebe Resource Deployer not using the host logger

Describe the bug

The Zeebe Resource Deployer does not use the same logger as the rest of the code as observed here:
Github

I believe this is because its using its own create logger instead of inputting the default one through the constructor

To Reproduce

Steps to reproduce the behavior:

  1. Replace the default logger with a different logger like Serilog
  2. Run a deployment of resources with other logs
  3. Observe different formatting of logs

Expected behavior

The logs for resource deployer follow the same format as the rest of the logging

Enviroment (please complete the following information):

  • OS: Windows
  • Version 1.0.2

Additional context

Add any other context about the problem here.

Dependency Dashboard

This issue lists Renovate updates and detected dependencies. Read the Dependency Dashboard docs to learn more.

Open

These updates have all been created already. Click a checkbox below to force a retry/rebase of any.

Ignored or Blocked

These are blocked by an existing closed PR and will not be recreated unless you click a checkbox below.

Detected dependencies

github-actions
.github/workflows/analyze.yml
  • actions/checkout v4
  • github/codeql-action v3
  • actions/setup-dotnet v4.0.0
  • github/codeql-action v3
  • github/codeql-action v3
.github/workflows/build.yml
  • actions/checkout v4
  • actions/setup-dotnet v4.0.0
.github/workflows/deploy.yml
  • actions/checkout v4
  • actions/setup-dotnet v4.0.0
.github/workflows/preview-deploy.yml
  • actions/checkout v4
  • actions/setup-dotnet v4.0.0
nuget
src/Zeebe.Client.Accelerator/Zeebe.Client.Accelerator.csproj
  • zb-client 2.6.0
  • Microsoft.Extensions.Options.ConfigurationExtensions 6.0.0
  • Microsoft.Extensions.Hosting 6.0.1
  • Microsoft.Extensions.Configuration.Abstractions 6.0.0
  • zb-client 2.6.0
  • Microsoft.Extensions.Options.ConfigurationExtensions 7.0.0
  • Microsoft.Extensions.Hosting 7.0.1
  • Microsoft.Extensions.Configuration.Abstractions 7.0.0
  • zb-client 2.6.0
  • Microsoft.Extensions.Options.ConfigurationExtensions 8.0.0
  • Microsoft.Extensions.Hosting 8.0.0
  • Microsoft.Extensions.Configuration.Abstractions 8.0.0

  • Check this box to trigger a request for Renovate to run again on this repository

I can use the client to subscribe to a task without completing it

Problem : I want to subscribe to usertasks to push them through websockets to the front-end without waiting for tasklist to be populated. I'd like a worker without autocomplete

Solution : A worker that is Void and not completing the job.

Alternative : sending an exception... but that's ugly :)

Thanks in advance for any feedback (perhaps I missed something)

Do not detect and register abstract classes as ZeebeWorker

When creating a abstract class for Zeebe worker class, so that the abstract class inherits from IAsyncZeebeWorker<TInput> or IAsyncZeebeWorker<TInput, TResponse> interfaces then dependency injection exception is thrown because abstract class is detected as Zeebe worker and instance instantiation fails.

Exception

System.ArgumentException: Cannot instantiate implementation type '...BaseAsyncWorker`1[TInput]' for service type '...BaseAsyncWorker`1[TInput]'.
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.Populate()
   at Microsoft.Extensions.DependencyInjection.ServiceProvider..ctor(ICollection`1 serviceDescriptors, ServiceProviderOptions options)
   at Microsoft.Extensions.DependencyInjection.ServiceCollectionContainerBuilderExtensions.BuildServiceProvider(IServiceCollection services, ServiceProviderOptions options)
   at Microsoft.Extensions.Hosting.HostApplicationBuilder.Build()
   at Microsoft.AspNetCore.Builder.WebApplicationBuilder.Build()
  at Program.<Main>$(String[] args) in Program.cs:line NN

Example of the abstract class

public abstract class BaseAsyncWorker<TInput>, IAsyncZeebeWorker<TInput>
    where TInput : BaseVariables, new()
{
    protected abstract Task HandleJob(TInput variables, CancellationToken cancellationToken);

    public async Task HandleJob(ZeebeJob<TInput> job, CancellationToken cancellationToken)
    {
        var variables = job.getVariables();

        Exception? exception = null;
        try
        {
            await HandleJob(variables, cancellationToken);
            OnSuccess(variables);
        }
        catch (Exception e)
        {
            exception = e;
            OnError(variables, e);
            throw;
        }
        finally
        {
            await StoreStateForJob(job, exception, cancellationToken);
        }
    }
    // Base class methods omitted for brevity
}

Example of the worker class inheriting from the abstract class BaseAsyncWorker

public class StartSomethingAsyncWorker : BaseAsyncWorker<MyJobVariables>
{
    protected override Task HandleJob(MyJobVariables variables, CancellationToken cancellationToken)
    {
        return apiClient.DoSomethingAsync(variables.MyUniqueId, cancellationToken);
    }
}

Solution

Solution would be to detect if the type is abstract class JobHandlerInfoProvider and not to register the class as worker in ZeebeHostedService method Task StartAsync(CancellationToken cancellationToken).

Proposed change in JobHandlerInfoProvider

private static bool IsZeebeWorker(Type t)
{
    if (t.IsAbstract) return false;
    
    var interfaces = t.GetInterfaces();
    return
        interfaces.Contains(typeof(IZeebeWorker)) ||
        interfaces.Contains(typeof(IAsyncZeebeWorker))
        || interfaces.Any(i => i.IsGenericType && GENERIC_HANDLER_TYPES.Contains(i.GetGenericTypeDefinition()));
}

See the bool IsZeebeWorker(Type t) method

and bool IsZeebeWorkerInterface(Type i) method
private static bool IsZeebeWorkerInterface(Type i)
in JobHandlerInfoProvider class.

Also

foreach (var jobHandlerInfo in jobHandlerInfoProvider.JobHandlerInfoCollection)

ZeebeClient in the example application can send the command successfully for only once

Describe the bug

The command NewCreateProcessInstanceCommand() only gets sent to the local Zeebe docker via ZeebeClient successfully for once after the example web application is up and running and then it goes down for the subsequent requests without any errors.

To Reproduce

Steps to reproduce the behavior:

  1. Spin up the example web application
  2. Go to the Swagger UI provided by the example
  3. Submit an application
  4. Check the process instance in Operator. A new instance is created.
  5. Go to Swagger UI and submit another application
  6. Check the process instance in Operator. The number of the instance remains the same.

Expected behavior

A new process instance should be created whenever a new application is submitted via ZeebeClient.

Enviroment (please complete the following information):

  • OS: Windows
  • Version: 11
  • Zeebe: 8.2.11 (running in Docker Desktop)

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.