Będąc wciąż pod dużym wrażeniem możliwości, jakie dają Azure Functions, postanowiłem zbudować w końcu rozwiązanie dla Nintex Workflow w Office 365, które pozwoli mi publikować zapisane wcześniej przepływy pracy w różnych listach w danej witrynie. Dotąd, by osiągnąć ten cel, używałem PowerShell lub aplikacji takich jak Postman czy Fiddler. Jednak zależało mi na tym, by mieć jeden przepływ pracy, który całą tę magię wykona za mnie.

Nintex dla Office 365 REST API

Niestety, REST API jakie posiada Nintex dla Office 365 nie jest tak elastyczne i użyteczne jak to, z którego można korzystać pracując z wersjami on-premise. Więcej o możliwościach przeczytaj tutaj. Ja skupiłem się na metodzie „Import into an existing workflow” służącej do nadpisania istniejącego przepływu pracy innym.

Wymagania wstępne

Zanim zaczniesz w ogóle myśleć nad wołaniem Nintex REST API w Office 365 musisz posiadać klucz API Key. jest on bezwględnie wymagany w celu uwierzytelnienia zapytań. Możesz taki otrzymać, wypełniając formularz dostępny tutaj.

Po otrzymaniu klucza przygotuj pozostałe, wymagane dane:

  1. Login i hasło użytkownika, w którego kontekście wykonywane będą zapytania.
  2. Base URL, który jest zbudowany korzystając z wzoru: {customer}.nintexo365.com/api/{version}
    gdzie:

    1. {customer} to po prostu nazwa Twojego tenanta.
    2. {version} – aktualnie jedynie v1 jest dostępna.
  3. GUID źródłowego przepływu pracy oraz docelowych przepływów, które mają zostać nadpisane.
  4. Nazwy list/ bibliotek gdzie docelowo ma trafić przepływ pracy.

Prościzna, prawda? Nie do końca. Jedyną rzeczą, jakiej nie da się zautomatyzować w tym zadaniu, to pozyskiwanie identyfikatora GUID przepływu pracy. By go zdobyć, należy wpierw wyeksportować workflow, następnie otworzyć plik NWP korzystając np. z 7ZIP, potem wejść do folderu „Workflow” i otworzyć plik „Metadata.xml”. W nim znajduje się poszukiwany GUID:

NIntex Workflow GUID

Azure Function

Funkcja wykonuje trzy czynności:

  1. Otrzymuje żądanie POST, w którym dostaje niezbędne do działania informacje i pobiera zawartość pliku z definicją workflowu;
  2. Pozyskuje ciasteczko SPOIDCRL dla podanych danych logowania;
  3. Woła funkcję Nintex i nadpisuje wskazany workflow, workflowem źródłowym.

Funkcja jest napisana w C# i wygląda jak poniżej:

#r "Microsoft.SharePoint.Client.dll"
#r "Microsoft.SharePoint.Client.Runtime.dll"
#r "Newtonsoft.Json"

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.SharePoint.Client;
using System.Security;
using System.Net.Http.Headers;
using System.Net.Http;
using System.Net;
using System.IO;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

// The API key and root URL for the REST API.
static private string apiKey = "APIKEY";
static private string apiRootUrl = "BASEURL";

// The SharePoint site and credentials to use with the REST API.
static private string spSiteUrl = "";
static private string spUsername = "USERLOGIN";
static private string spPassword = "USERPASS";

public static async Task<HttpResponseMessage> Run(HttpRequestMessage inputData, TraceWriter log)
{
    // Get request body
    dynamic data = await inputData.Content.ReadAsAsync<object>();
    string SourceWorkflowGUID = data?.SourceWorkflowGUID;
    string SourceWorkflowFileREST = data?.SourceWorkflowFileREST;
    string TenantName = data?.TenantName;
    spSiteUrl = data?.CurrentSiteURL; 

    string TargetWorkflowGUID = data?.TargetWorkflowGUID;
    string TargetListName = data?.TargetListName;

    // Create a new HTTP client and configure its base address.
    HttpClient Nintex_API_Client = new HttpClient();

    // Create a handler for HTTP Client for calling SP endpoints to create a cookie
    HttpClientHandler SP_API_Client_handler = new HttpClientHandler();
    SP_API_Client_handler.CookieContainer = new CookieContainer();

    // Get the SharePoint authorization cookie to be used by the HTTP client
    // for the request, and use it for the Authorization request header.
    string spoCookie = GetSPOCookie(log);
    
    if (spoCookie != String.Empty)
    {
        var authHeaderNintex = new AuthenticationHeaderValue(
            "cookie",
            String.Format("{0} {1}", spSiteUrl, spoCookie)
        );
        
        // Add the defined authentication header to the HTTP client's
        // default request headers.
        Nintex_API_Client.DefaultRequestHeaders.Authorization = authHeaderNintex;
        
        // Set auth cookie for SP HTTP Client
        SP_API_Client_handler.CookieContainer.Add(
          new Cookie("SPOIDCRL",
        spoCookie.TrimStart("SPOIDCRL=".ToCharArray()),
        String.Empty,
        new Uri(spSiteUrl).Authority));        
    }
    else
    {
        throw new InvalidOperationException("Cannot define Authentication header for request.");
    }

    HttpClient SP_API_Client = new HttpClient(SP_API_Client_handler);
    Nintex_API_Client.BaseAddress = new Uri(spSiteUrl);
    SP_API_Client.BaseAddress = new Uri(spSiteUrl);

    // Add common request headers for the REST API to the HTTP client.
    Nintex_API_Client.DefaultRequestHeaders.Accept.Add(
        new MediaTypeWithQualityHeaderValue("application/json"));
    Nintex_API_Client.DefaultRequestHeaders.Add("Api-Key", apiKey);

    // Add headers for the SP HTTP client.
    SP_API_Client.DefaultRequestHeaders.Accept.Add(
        new MediaTypeWithQualityHeaderValue("application/json"));    

    // Get contents of an exported workflow from the source list.
    // so that it can be used as a source 
    var workflowFileUri = String.Format("{0}/_api/web/getfilebyserverrelativeurl('{1}')/$value?binaryStringResponseBody=true",
        spSiteUrl.TrimEnd('/'),
        Uri.EscapeUriString(SourceWorkflowFileREST));
    
    HttpResponseMessage exportResponse = SP_API_Client.GetAsync(workflowFileUri).Result;
    
    // If we're successful, import the exported workflow to the destination list, as a new workflow.
    if (exportResponse.IsSuccessStatusCode)
    {
        // The response body contains a Base64-encoded binary string, which we'll
        // asynchronously retrieve as a byte array.
        byte[] exportFileContent = await exportResponse.Content.ReadAsByteArrayAsync();
          
        // Next, import the exported workflow to the destination list.
        var importWorkflowUri = String.Format("{0}/api/v1/workflows/packages/{1}?listTitle={2}",
            apiRootUrl.TrimEnd('/'),
            TargetWorkflowGUID,
            Uri.EscapeUriString(TargetListName));
        
        // Create a ByteArrayContent object to contain the byte array for the exported workflow.
        var importContent = new ByteArrayContent(exportFileContent);

        // Send a POST request to the REST resource.
        HttpResponseMessage importResponse = Nintex_API_Client.PutAsync(importWorkflowUri, importContent).Result;
        
        // Indicate to the console window the success or failure of the operation.
        if (importResponse.IsSuccessStatusCode)
        {
            var publishWorkflowUri = String.Format("{0}/api/v1/workflows/{1}/published",
            apiRootUrl.TrimEnd('/'),
            TargetWorkflowGUID);

            HttpResponseMessage publishResponse = Nintex_API_Client.PostAsync(publishWorkflowUri, importContent).Result;

            if (publishResponse.IsSuccessStatusCode)
            {
                return inputData.CreateResponse(HttpStatusCode.OK, "{\"StatusCode\":"+(int)publishResponse.StatusCode + ",\"ReasonPhrase\":\"" + publishResponse.ReasonPhrase + "\",\"Message\":\"Workflow imported and published\"}"); 
            }
            else 
            {
                return inputData.CreateResponse(HttpStatusCode.OK, "{\"StatusCode\":"+(int)publishResponse.StatusCode + ",\"ReasonPhrase\":\"" + publishResponse.ReasonPhrase + "\",\"Message\":\"Workflow imported but not published\"}"); 
            }

        }
        else
        {
            return inputData.CreateResponse(HttpStatusCode.BadRequest, "{\"StatusCode\":"+(int)importResponse.StatusCode + ",\"ReasonPhrase\":\"" + importResponse.ReasonPhrase + "\",\"Message\":\"Error importing and publishing a workflow\"}");
        }   
    }

    return inputData.CreateResponse(HttpStatusCode.BadRequest, "{\"StatusCode\":"+(int)exportResponse.StatusCode + ",\"ReasonPhrase\":\"" + exportResponse.ReasonPhrase + "\",\"Message\":\"Error getting source workflow contents\"}");

}

// Function for getting the SPOIDCRL cookie, needed for authentication
static private string GetSPOCookie(TraceWriter log)
{
    string result = String.Empty;
    try
    {
        // Construct a secure string from the provided password.
        var securePassword = new SecureString();
        foreach (char c in spPassword) { securePassword.AppendChar(c); }

        // Initialize a new SharePointOnlineCredentials object, using the 
        // specified username and password.
        var spoCredential = new SharePointOnlineCredentials(spUsername, securePassword);
        
        // If successful, try to authenticate the credentials for the specified site.
        if (spoCredential == null)
        {
            // Credentials could not be created.
            result = String.Empty;
        }
        else
        {
            // Credentials exist, so attempt to get the authentication cookie
            // from the specified site.
            result = spoCredential.GetAuthenticationCookie(new Uri(spSiteUrl));
        }
    }
    catch (Exception ex)
    {
        Console.WriteLine(ex.ToString());
        result = String.Empty;
    }

    
    return result;
}

Podczas jej kompilacji może się okazać, że brakuje bibliotek SharePoint. Należy je dodać jako referencje. Przeczytaj tutaj jak to zrobić. Funkcja jest skonfigurowana tak, by uruchamiać się na żądania POST. Treść żądania to płaski JSON, zawierający poniższe dane:

{
"SourceWorkflowGUID": "GUID",
"SourceWorkflowFileREST": "SITE RELATIVE URL TO THE SOURCE WORKFLOW NWP FILE",
"TargetWorkflowGUID": "GUID",
"TargetListName": "TARGET LIST NAME",
"TenantName": "TENANT NAME",
"CurrentSiteURL": "SITE URL"
}

Nintex Workflow i ustawienia biblioteki

Biblioteka, w której trzymam definicje przepływów pracy posiada poniższe, dodatkowe kolumny:

  1. TargetWorkflowGUIDS (rozdzielone średnikami)
  2. TargetListNames (rozdzielone średnikami)
  3. SourceWokrflowGUID

Przepływ jest prosty – czyta metadane z biblioteki, rozdziela GUIDy i następnie w pętli, dla każdego najpierw konstruuje JSON z treścią żądania i finalnie woła funkcję w Azure używając akcji „Web Request”.

   URL by wywołać Azure Function jest zawsze zbudowany korzystając ze wzorca: https://{function app name}.azurewebsites.net/api/{function name}

Nintex Workflow to call Azure Function

Podsumowanie i uwagi

O ile to rozwiązanie naprawdę działa bezbłędnie, jest jednak pewna kwestia, która mnie niepokoi. W przypadku pracy z interfejsem webowym Nintex Designer, nie ma możliwości zapisania i publikacji drugiego workflow, o ile w danej witrynie istnieje inny, posiadający taką samą nazwę. Jednak korzystając z REST API można publikować dowolnie wiele przepływów o takiej samej nazwie. Nie sprawdzałem, co się stanie, gdy spróbuje się użyć akcji „Start workflow”, ale podejrzewam, że wykonujący ją przepływ zostanie wstrzymany „Suspended” z powodu niemożliwości wybrania pojedynczego przepływu o wskazanej nazwie.

A;e zapraszam do głosowania na UserVoice za dodaniem funkcji, pozwalającej na określenie nazwy przepływu pracy, przed jego zapisaniem, korzystając z REST API: https://nintex.uservoice.com/forums/218291-3-nintex-workflow-for-office-365/suggestions/33048127-nintex-rest-api-should-allow-to-change-imported-wo.

Cześć! Nazywam się Tomasz. Jestem wielkim fanem automatyzacji procesów i analizy biznesowej. Skupiam się na rozwijaniu moich umiejętności w pracy z produktami Nintex i Microsoft: w szczególności Office 365, SharePoint. Posiadam ponad 8 lat doświadczenia w pracy z SharePoint.