DEV Community

Cover image for Bind Azure Functions to SAP Event Mesh … What?
Christian Lechner
Christian Lechner

Posted on

Bind Azure Functions to SAP Event Mesh … What?

Introduction

In a lot of business processes in some point in time we must touch SAP system as the ERP backbone of many companies. There are tons of use-cases with their specific requirements to feed data into SAP and there are also a lot of options to achieve this with corresponding pros and cons.

For sure we can directly call exposed APIs of the SAP systems or connect via integration services like SAP Cloud Integration.

In addition, there is a (quite) new player in the game called SAP Event Mesh that allows us to send messages and events to via different channels. That sounds like a nice building block to setup a resilient and extensible way to communicate with SAP solutions with the big advantage that we do not have to care about which systems to call exactly. We send a message in a queue and the relevant subscribers can get the access to the needed information.

Let us assume the starting point of our process is outside of SAP as we have developed a serverless app using Azure Functions and at some point in the process we want to send a message to the SAP system making use of the SAP Event Mesh to trigger follow-up processes in the ERP.

This picture gives you a rough overview of an example flow (some components on BTP side might be missing depending on how to consume and process the messages):

Overview Message Flow

How can we do that? There is a REST API that allows us to interact with the SAP Event Mesh. But before calling the REST API we must do the OAuth dance and fetch the bearer token to authenticate our call. Okay we can do that in our Azure Function. Problem solved.

Wait a second: in a real-world scenario, there will be not only one business process or one Azure Function app that communicates with the SAP Event Mesh but there will be several. One solution would be the brute force one – every app implements the flow (OAuth + call of the REST API) by themselves. It’s not rocket-science but cumbersome and in case of changes e. g. in the API every app must adopt its code. This is for several reasons like maintainability not the best approach.

Is there a better way to centralize the steps to send a message to the SAP Event Mesh and make the consumption of this functionality in an Azure Function as smooth as possible? And indeed, there is: Azure Functions allow the implementation of custom bindings to exactly achieve such a flow (or to be more precise this functionality is delivered by the underlying WebJobsSDK).

Let’s explore how we can do that and how complicated it is to achieve a first working setup.

Setup

To test the scenario, we need an SAP Event Mesh. Luckily this service is part of the SAP Business Technology Platform trial and available for free.

However, the quality of the documentation as well as the one of the management UI has room for improvement. The trial account offers a (deprecated) version of the SAP Event Mesh in the so called dev plan. For the inital setup follow along the description for the deprecated lite service plan (you get the fun, I guess: looking for the setup of the dev plan will not guide you to the correct documentation).

This version might be discontinued in the future, but this is the easiest setup and with respect to the implementation of the binding there are no differences.

After we have executed the initial setup according to the documentation and created an instance of the SAP Event Mesh we must create so called service keys to get access to the to it e. g. via the REST APIs:

SAP Event Mesh Create Service Keys

The service key contains the relevant REST endpoints for the OAuth token, credentials and the base URL of the SAP Event Mesh:

SAP Event Mesh - Service Key

As last step of the setup we must navigate to the dashboard of the SAP Event Mesh from the service instance:

Navigation to SAP Event Mesh Dashboard

and create a queue to put our messages into:

SAP Event Mesh Queue

From the SAP Event Mesh side everything is put in place to start the implementation of the custom binding.

💡 Additional Info: If you want to dive deeper into the topic of the SAP Event Mesh I highly recommend the YouTube playlist of DJ Adams that provides a lot more details and brings in some CLI magic on top – this helped me a lot!

The Custom Binding

Azure Functions come along with the concept of bindings which allows us to connect to resources outside of the function in a declarative way.
There are three types of bindings:

  • Trigger: connection to a resource that triggers the execution of the function
  • Input: fetching data from a resource as input for the function
  • Output: sending data to a resource as output of the function

Azure Functions come with some bindings out of the box, but of course not for the SAP Event Mesh. Luckily bindings can be extended via own implementations that can be consumed from an Azure Function via the standard binding mechanisms.

For our scenario we need an output binding that we use to propagate a message to a queue in the SAP Event Mesh. The binding must take a message from the function, do the authentication and then propagate the message to the SAP Event Mesh via the REST endpoint.

What do we have to do to make this reality? Let’s walk through it.

Remark: These are my first steps into the .NET and C# area, so bear with me with respect to the quality of the code. Happy for any input on improving in that area.

Step 1 – Project Setup

We create a directory (e.g. EventMeshCustomBinding) and in it we setup the basic skeleton of the custom binding via the dotnet CLI:

dotnet new classlib
Enter fullscreen mode Exit fullscreen mode

Make sure that the target framework in the project properties is set to .NET Core 3.1.

Next we add a .gitignore file via CLI.

dotnet new gitignore
Enter fullscreen mode Exit fullscreen mode

Scaffolding done, let's move on to the code.

Step 2 – Definition of Binding Data

Now we create the classes needed for the declarative definition of the binding parameters (like credentials and endpoint) as well as the data we expect from the Azure Function to be handed to the SAP Event Mesh.

We define a class EventMeshMessage.cs that represents our message that we receive from the Azure Function. We assume that just the message is handed over, so the class looks like this:

namespace EventMeshCustomBinding
{ 
    public class EventMeshMessage
    {
        public string message { get; set; } = default!;
    }
}
Enter fullscreen mode Exit fullscreen mode

We do the same for the declarative definition of the binding parameters by creating the class EventMeshAttribute.

The class contains the parameters that we expect from the configuration of the binding as property. It inherits from the Attribute base class:

using Microsoft.Azure.WebJobs.Description;
using System;

namespace EventMeshCustomBinding
{
    [Binding]
    [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.ReturnValue)]

    public class EventMeshAttribute : Attribute
    {
        [AutoResolve]
        public string EventMeshTokenEndpoint {  get; set; } = default!;

        [AutoResolve]
        public string EventMeshClientId { get; set; } = default!;

        [AutoResolve]
        public string EventMeshClientSecret { get; set; } = default!;

        [AutoResolve]
        public string EventMeshGrantType { get; set; } = default!;

        [AutoResolve]
        public string EventMeshRestBaseEndpoint { get; set; } = default!;

        [AutoResolve]
        public string EventMeshQueueName { get; set; } = default!;
    }
}
Enter fullscreen mode Exit fullscreen mode

We added some attributes to the class namely the [Binding] attribute to mark it as a binding for the WebJobSDK as well as the usage of the defined attributes via [AttributeUsage].

In addition, we annotate every property of the class with the [AutoResolve] attribute to wire it up with the function i. e. with the parameters defined in the function.

Step 3 – The Async Collector aka the Core Part

The application logic behind the binding is implemented using the interface IAsyncCollector. Here is the complete code of the class:

using Microsoft.Azure.WebJobs;
using Newtonsoft.Json;
using RestSharp;
using System;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Web;

namespace EventMeshCustomBinding
{
    internal class EventMeshAsyncCollector : IAsyncCollector<EventMeshMessage>
    { 
        private string _eventMeshTokenEndpoint; 
        private string _eventMeshClientId;
        private string _eventMeshClientSecret;
        private string _eventMeshGrantType;
        private string _eventMeshRestBaseEndpoint;
        private string _eventMeshQueueName;

        public EventMeshAsyncCollector(EventMeshBinding config, EventMeshAttribute attr)
        {
            _eventMeshTokenEndpoint = attr.EventMeshTokenEndpoint;
            _eventMeshClientId = attr.EventMeshClientId;
            _eventMeshClientSecret = attr.EventMeshClientSecret;
            _eventMeshGrantType = attr.EventMeshGrantType;
            _eventMeshRestBaseEndpoint = attr.EventMeshRestBaseEndpoint;
            _eventMeshQueueName = attr.EventMeshQueueName;
        }

        public async Task AddAsync(EventMeshMessage message, CancellationToken cancellationToken = default)
        {
            try
            {

                var token = await GetOauthToken();

                var restEndPointWithQueue = $"{_eventMeshRestBaseEndpoint}/messagingrest/v1/queues/{HttpUtility.UrlEncode(_eventMeshQueueName)}/messages"; 

                var client = new RestClient(restEndPointWithQueue);

                client.Timeout = -1;

                var request = new RestRequest(Method.POST);

                request.AddHeader("x-qos", "0");

                request.AddHeader("Content-Type", "application/json");

                request.AddHeader("Authorization", $"{token.TokenType} {token.AccessToken}");

                var body = JsonConvert.SerializeObject(message, Formatting.Indented);

                request.AddParameter("application/json", body, ParameterType.RequestBody);

                IRestResponse response = await client.ExecuteAsync(request);

            }
            catch (Exception e)
            {

                Console.WriteLine(e.Message);
            }
        }

        public Task FlushAsync(CancellationToken cancellationToken = default)
        {
            return Task.CompletedTask;
        }


        private async Task<Token> GetOauthToken( )
        {

            var tokenUrl = $"{_eventMeshTokenEndpoint}?grant_type={_eventMeshGrantType}&response_type=token";

            var client = new RestClient(tokenUrl);

            client.Timeout = -1;

            var request = new RestRequest(Method.POST);

            var base64EncodedCredentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_eventMeshClientId}:{_eventMeshClientSecret}"));

            request.AddHeader("Authorization", $"Basic {base64EncodedCredentials}");

            var body = @"";

            request.AddParameter("text/plain", body, ParameterType.RequestBody);

            IRestResponse response = await client.ExecuteAsync(request);

            Token bearerToken = JsonConvert.DeserializeObject<Token>(response.Content);

            return bearerToken;
        }

        internal class Token
        {
            [JsonProperty("access_token")]
            public string AccessToken { get; set; } = default!;

            [JsonProperty("token_type")]
            public string TokenType { get; set; } = default!;

            [JsonProperty("expires_in")]
            public int ExpiresIn { get; set; } = default!;

            [JsonProperty("refresh_token")]
            public string RefreshToken { get; set; } = default!;
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Let’s walk through it step by step. The class consists of three main parts:

  • The constructor
  • The AddAsync method
  • The FlushAsync method

The constructor receives the attribute values of the binding i. e. the configuration parameters that we store in private properties of the class:

public EventMeshAsyncCollector(EventMeshBinding config, EventMeshAttribute attr)
        {
            _eventMeshTokenEndpoint = attr.EventMeshTokenEndpoint;
            _eventMeshClientId = attr.EventMeshClientId;
            _eventMeshClientSecret = attr.EventMeshClientSecret;
            _eventMeshGrantType = attr.EventMeshGrantType;
            _eventMeshRestBaseEndpoint = attr.EventMeshRestBaseEndpoint;
            _eventMeshQueueName = attr.EventMeshQueueName;
        }
Enter fullscreen mode Exit fullscreen mode

The FlushAsync method returns the successful completed task and needs to be implemented as part of the IAsyncCollector interface, no real magic in there:

public Task FlushAsync(CancellationToken cancellationToken = default)
        {
            return Task.CompletedTask;
        }
Enter fullscreen mode Exit fullscreen mode

The main logic is located in the method AddAsync. In our case it contains the logic to get the OAuth token via the private method GetOauthToken:

private async Task<Token> GetOauthToken( )
        {

            var tokenUrl = $"{_eventMeshTokenEndpoint}?grant_type={_eventMeshGrantType}&response_type=token";

            var client = new RestClient(tokenUrl);

            client.Timeout = -1;

            var request = new RestRequest(Method.POST);

            var base64EncodedCredentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_eventMeshClientId}:{_eventMeshClientSecret}"));

            request.AddHeader("Authorization", $"Basic {base64EncodedCredentials}");

            var body = @"";

            request.AddParameter("text/plain", body, ParameterType.RequestBody);

            IRestResponse response = await client.ExecuteAsync(request);

            Token bearerToken = JsonConvert.DeserializeObject<Token>(response.Content);

            return bearerToken;
        }
Enter fullscreen mode Exit fullscreen mode

and the call of the SAP Event Mesh REST endpoint to transfer the message to authenticating via the Bearer token.

{
            try
            {

                var token = await GetOauthToken();

                var restEndPointWithQueue = $"{_eventMeshRestBaseEndpoint}/messagingrest/v1/queues/{HttpUtility.UrlEncode(_eventMeshQueueName)}/messages"; 

                var client = new RestClient(restEndPointWithQueue);

                client.Timeout = -1;

                var request = new RestRequest(Method.POST);

                request.AddHeader("x-qos", "0");

                request.AddHeader("Content-Type", "application/json");

                request.AddHeader("Authorization", $"{token.TokenType} {token.AccessToken}");

                var body = JsonConvert.SerializeObject(message, Formatting.Indented);

                request.AddParameter("application/json", body, ParameterType.RequestBody);

                IRestResponse response = await client.ExecuteAsync(request);

            }
            catch (Exception e)
            {

                Console.WriteLine(e.Message);
            }
        }
Enter fullscreen mode Exit fullscreen mode

We use an internal class Token to map the token data from the REST call to an object. In this class we use the attribute JsonProperty on the properties to "automagically" transfer the right data to the properties when serializing the JSON string from the REST call of the OAuth endpoint:

internal class Token
        {
            [JsonProperty("access_token")]
            public string AccessToken { get; set; } = default!;

            [JsonProperty("token_type")]
            public string TokenType { get; set; } = default!;

            [JsonProperty("expires_in")]
            public int ExpiresIn { get; set; } = default!;

            [JsonProperty("refresh_token")]
            public string RefreshToken { get; set; } = default!;
        }
Enter fullscreen mode Exit fullscreen mode

Some points to put attention to:

  • For the OAuth token we need to transfer the clientid and the clientsecret base64 encoded.
  • The queue name is part of the URL of the SAP Event Mesh. We must therefore URL encode the string.

Now it is time to wire things up.

💡 Additional Info: When implementing the binding I first made sure that the REST calls are working properly. For that I executed the calls via Postman. Postman offers an option to create the code for different languages and libraries which made the transfer to the C# world much easier for me 😊

Step 4 – Connecting the Pieces

To integrate the logic into the binding mechanism of Azure Functions we need to implement two more classes:

First we need a config provider class to wire the message and the configuration together with the async collector. We achieve this with few lines of code implementing the interface IExtensionConfigProvider in the class EventMeshBinding:

using Microsoft.Azure.WebJobs.Description;
using Microsoft.Azure.WebJobs.Host.Config;

namespace EventMeshCustomBinding
{
    [Extension("EventMeshBinding")]


public class EventMeshBinding : IExtensionConfigProvider
    {


        public void Initialize(ExtensionConfigContext context)
        {
            context.AddConverter<string, EventMeshMessage>(input => new EventMeshMessage { message = input });
            context.AddBindingRule<EventMeshAttribute>().BindToCollector(attr => new EventMeshAsyncCollector(this, attr));
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

We mark the class as an extension using the corresponding attribute and we use the Initialize method:

  • to convert the input delivered as a string to the EventMessage object.
  • to bind the async collector and the configuration attributes together

💡 Additional Info: There is much more that you can do there like validating the input, but let’s start with this setup.

Having this glue code in place we need to make the runtime aware of the extension. This is done via a startup class that we call EventMeshBindingStartup:

using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Hosting;
using EventMeshCustomBinding;

[assembly: WebJobsStartup(typeof(EventMeshBindingStartup))]

namespace EventMeshCustomBinding
{
    public class EventMeshBindingStartup : IWebJobsStartup
    {
        public void Configure(IWebJobsBuilder builder)
        {

            builder.AddExtension<EventMeshBinding>();

        }

    }
}
Enter fullscreen mode Exit fullscreen mode

Now we published our newly created SAP Event Mesh binding to the WebJob builder.

Code-wise everything is in place, so let's build the dll

Step 5 – Building the DLL

As we have the code in place, we must build the project via

dotnet build
Enter fullscreen mode Exit fullscreen mode

This creates the necessary *.dll file for our Azure Function binding.

Usage of the Custom Binding in an Azure Function

We create an HTTP triggered Azure Function in TypeScript in order to wire this one up with our new custom binding.

Azure Functions in non-.NET languages use the so called extension bundle mechanism to install the needed bindings. As we want to install our own binding we need to bypass this mechanism and bring in our dll. Here are the steps to achieve this:

  • We add a folder extension to your Azure Functions project.
  • We copy the dll file of the custom binding into the folder.
  • We remove the extensionbundle section from the host.json file. This switches off the extension bundle mechanism
  • We initialize the Azure Functions project with a extensions.csproj file via the CLI command func extensions sync
  • We add the needed dependencies of our custom binding to the extensions.csproj file namely for our extension namely:
 <PackageReference Include="Microsoft.Azure.WebJobs" Version="3.0.27" />
 <PackageReference Include="RestSharp" Version="106.12.0" />
Enter fullscreen mode Exit fullscreen mode
  • We execute func extensions install to install our binding extension. To check if this was successful we take a look into the file extension.json in the bin directory. There should be an entry for the SAP Event Mesh binding in there.
  • We add the output binding to the function in its function.json file:
{
    "type": "EventMesh",
    "direction": "out",
    "name": "EventMeshMessage",
    "EventMeshTokenEndpoint": "%EventMeshTokenEndpoint%",
    "EventMeshClientId": "%EventMeshClientId%",
    "EventMeshClientSecret": "%EventMeshClientSecret%",
    "EventMeshGrantType": "%EventMeshGrantType%",
    "EventMeshRestBaseEndpoint": "%EventMeshRestBaseEndpoint%",
    "EventMeshQueueName": "%EventMeshQueueName%"
  }
Enter fullscreen mode Exit fullscreen mode
  • We add the connection values for the SAP Event Mesh into the local settings.json like:
 {
 "IsEncrypted": false,
 "Values": {
   "AzureWebJobsStorage": "",
   "FUNCTIONS_WORKER_RUNTIME": "node",
   "EventMeshTokenEndpoint": "<YOUR EVENT MESH TOKEN ENDPOINT>",
   "EventMeshClientId": "<YOUR EVENT MESH CLIENT ID>",
   "EventMeshClientSecret": "<YOUR EVENT MESH CLIENT SECRET>",
   "EventMeshGrantType": "client_credentials",
   "EventMeshRestBaseEndpoint": "<YOUR EVENT MESH REST ENDPOINT>",
   "EventMeshQueueName": "<YOUR EVENT MESH QUEUE NAME>"
   }
 }
Enter fullscreen mode Exit fullscreen mode

This setup is due to the [AutoResolve] attribute in our binding.

We adjust the functions code to send a simple message to the queue:

import { AzureFunction, Context, HttpRequest } from "@azure/functions"

const httpTrigger: AzureFunction = async function (context: Context, req: HttpRequest): Promise<void> {
    context.log('HTTP trigger function processed a request.');

    let responseMessage: string

    if (req.body && req.body.message) {

        context.bindings.EventMeshMessage = req.body.message

         responseMessage = `This HTTP triggered function executed successfully. Message was: ${req.body.message}.`;

    }
    else {
        responseMessage = "No message to be sent"
    }

    context.res = {
        body: responseMessage
    }
}

export default httpTrigger
Enter fullscreen mode Exit fullscreen mode

Now we can start the function and in verbose mode we see that the extension gets picked up:

Azure Function Startup

Let us send a message to the function that will then be propagated to the SAP Event Mesh:

Send Message to SAP Event Mesh

and it will wait there to get consumed:

SAP Event Mesh - Message in Queue

By consuming it via a Postman we see that this was the one we sent before:

Consume message via Postman

Cool, our first custom output binding is up and running!

Summary

Talking to an SAP system is usually inevitable when developing business applications. There have been and there will be a lot of options to do that.

One cloud-native approach is available via the SAP Event Mesh. In this blog post we walked through the setup to combine Azure Functions with the SAP Event Mesh. To make this combination convenient for a developer we developed a custom binding to keep the code (and the mind of the developer) of the Azure Function free of any burden from authenticating against the SAP Event Mesh as well as the details on how to send a message to it.

Although this is only a first little step it shows that bridging the gap between the two worlds is not that complicated and custom applications implemented via Azure Functions can be combined with the SAP sphere without breaking your hands.

As an outlook: if we look at the code, we see that it still has some room for improvement (like error handling, unit testing and validations), so some more .NET to learn for me 😉.

In addition, it is certainly also interesting to have a custom trigger binding for the SAP Event Mesh. That levels up the difficulty at least for me, but something I will certainly have a closer look at.

Addendum 1 - The Code

All the code is available on GitHub:

Addendum 2 - Extension Bundles

Extensions are the way Azure Functions delivers the different bindings and keeps them out of the Azure Functions Core (keeping the core (c)lean - does that ring a bell reader from the SAP world?).

There are only two extensions backed into the core (HTTP and CRON). For all other ones like Kafka the extension needs to be installed in addition.

For non-.NET languages there is a mechanism that makes the live as developer easier when it comes to install the right extension, called extension bundle. This mechanism automatically installs the right extension in the version that is also used in the hosted environment of Azure. The mechanism is configured in the host.json file of the Azure Function and tells the runtime to install what is necessary (details which version needs to be installed are available here: https://github.com/Azure/azure-functions-extension-bundles/blob/v2.x/src/Microsoft.Azure.Functions.ExtensionBundle/extensions.json).

Technically the Azure Function host reaches out to a CDN and installs the function based on some constants hidden in the Azure Functions Host (see https://github.com/Azure/azure-functions-host/blob/9bdb40b2d517f32c5052956ba8c9cf662ea36a9e/src/WebJobs.Script/ScriptConstants.cs => search for ExtensionBundleDefaultSourceUri)

We maybe do not want to reach out to the CDN, use our own CDN or we want to pin a specific version or maybe want to switch to a later version than the CDN provides (updates of the versions in the ExtensionBundles do not occur as regularly as updates of extensions). How can we achieve that?

In the blog post we used a kind of brute-force approach to get our extension installed, which is okay for development purposes. But in a professional setup we may provide our own NuGet feed.

Discussion (0)