Business central blog
Subscribe to blog and get news about new posts.

Working with SOAP API and XML in AL. Generic API codeunit.

The purpose of this post is to learn the basics of working with XML and SOAP in AL. Besides, we'll look at my suggestion of the generic API method SendRequest(), which fits most APIs without authorization. The authorization itself is not difficult to add to the general logic, but in the post, they are not considered due to their large number and often unique logic.

Agenda

In my work practice, I often have to deal with a variety of APIs. After many iterations, I have developed "patterns" for working with APIs. One of these patterns is the SendRequest() method, which I use as a basis for sending HTTP requests and reading the result.
Since I'm using this method to write an article about SOAP/XML, I thought I'd also draw your attention to SendRequest(). I hope you will find it useful.

procedure SendRequest(contentToSend: Variant; RequestMethod: enum "Http Request Type"; requestUri: Text; ContentType: Text; HttpTimeout: integer; DictionaryContentHeaders: Codeunit "Dictionary Wrapper"; DictionaryDefaultHeaders: Codeunit "Dictionary Wrapper"): text
var
    Client: HttpClient;
    Request: HttpRequestMessage;
    Response: HttpResponseMessage;
    ContentHeaders: HttpHeaders;
    Content: HttpContent;
    ResponseText: Text;
    ErrorBodyContent: Text;
    TextContent: Text;
    InStreamContent: InStream;
    i: Integer;
    KeyVariant: Variant;
    ValueVariant: Variant;
    HasContent: Boolean;
begin
    case true of
        contentToSend.IsText():
            begin
                TextContent := contentToSend;
                if TextContent <> '' then begin
                    Content.WriteFrom(TextContent);
                    HasContent := true;
                end;
            end;
        contentToSend.IsInStream():
            begin
                InStreamContent := contentToSend;
                Content.WriteFrom(InStreamContent);
                HasContent := true;
            end;
        else
            Error(UnsupportedContentToSendErr);
    end;

    if HasContent then
        Request.Content := Content;

    if ContentType <> '' then begin
        ContentHeaders.Clear();
        Request.Content.GetHeaders(ContentHeaders);
        if ContentHeaders.Contains(ContentTypeKeyLbl) then
            ContentHeaders.Remove(ContentTypeKeyLbl);

        ContentHeaders.Add(ContentTypeKeyLbl, ContentType);
    end;

    for i := 0 to DictionaryContentHeaders.Count() do
        if DictionaryContentHeaders.TryGetKeyValue(i, KeyVariant, ValueVariant) then
            ContentHeaders.Add(Format(KeyVariant), Format(ValueVariant));

    Request.SetRequestUri(requestUri);
    Request.Method := Format(RequestMethod);

    for i := 0 to DictionaryDefaultHeaders.Count() do
        if DictionaryDefaultHeaders.TryGetKeyValue(i, KeyVariant, ValueVariant) then
            Client.DefaultRequestHeaders.Add(Format(KeyVariant), Format(ValueVariant));

    if HttpTimeout <> 0 then
        Client.Timeout(HttpTimeout);

    Client.Send(Request, Response);

    Response.Content().ReadAs(ResponseText);
    if not Response.IsSuccessStatusCode() then begin
        Response.Content().ReadAs(ErrorBodyContent);
        Error(RequestErr, Response.HttpStatusCode(), ErrorBodyContent);
    end;

    exit(ResponseText);
end;

var
    RequestErr: Label 'Request failed with HTTP Code:: %1 Request Body:: %2', Comment = '%1 = HttpCode, %2 = RequestBody';
    UnsupportedContentToSendErr: Label 'Unsuportted content to send.';
    ContentTypeKeyLbl: Label 'Content-Type', Locked = true;
This method works with the most popular HTTP request types: GET, POST, PATCH, PUT, DELETE, HEAD, and OPTIONS. Since I don't always need to pass all parameters to SendRequest() I use procedure overloading and it's pretty handy.
For example, if we need a simple GET request without any additional headers then we can use the overload option below. Of course, you can't send content or content headers in a GET request because GET doesn't support them.

local procedure SimpleGET()
var
    Result: Text;
begin
    Result := SendRequest(Enum::"Http Request Type"::GET, 'https://www.google.com/');
end;

procedure SendRequest(RequestMethod: enum "Http Request Type"; requestUri: Text): text
var
    DictionaryDefaultHeaders: Codeunit "Dictionary Wrapper";
    DictionaryContentHeaders: Codeunit "Dictionary Wrapper";
    ContentType: Text;
begin
    exit(SendRequest('', RequestMethod, requestUri, ContentType, 0, DictionaryContentHeaders, DictionaryDefaultHeaders));
end;
The most obvious way to learn how to work with the SOAP XML API in AL is a demo project using the free public SOAP API. For the demo project, I chose the online temperature converter https://www.w3schools.com/xml/tempconvert.asmx.
This is an example of the action CelsiusToFahrenheit SOAP 1.2 documentation. The documentation describes in detail what you need to send and what response the server returns.
We should also pay attention to WSDL which provides this API. Recall that WSDL is an XML-based interface description language that is used for describing the functionality offered by a web service: https://www.w3schools.com/xml/tempconvert.asmx?WSDL
Since reading XML is not very convenient, I use the Google Chrome extension - Wizdler. It is an extension that decrypts WSDL and offers you a suitable XML structure for sending requests to the web services. Open the WSDL that you are interested in browser and just click on the Wizdler icon, and the menu will open where you can choose what kind of request you want to send.
In my particular case, I chose CelsiusToFahrenheit 1.2, then Wizdler generated an XML that you can edit before sending and view the response XML after sending.
Once we've figured out the TempConversation SOAP API documentation we can move on to generating the correct XML in AL to send. There are several ways to do this. First, the simplest and most naive way is to simply "hardcode" the XML we need into a text variable. Obviously, this is a very bad way, because we won't be able to flexibly augment the XML or even parse it intelligently. Another way is to use the standard table 1235 "XML Buffer", which allows easy XML reading, but I don't really like it for creating new XML, and performance is rather low (for big XML). "XML Buffer" is still a good way to work with arbitrary XML. In some situations, it's the best possible way.
In this particular case, I like using AL XML DataTypes better, it is a productive and powerful way to work with XML in Business Central. So, if we examine the request XML of TempConversation API, we will see that they all have the same structure and differ slightly from each other. Only namespaces differ depends on SOAP Version 1.1 or 1.2 and the body of the XML depending on the required CelsiusToFahrenheit or FahrenheitToCelsius operation. You may also notice that Envelope and Body nodes start with soap or soap12, which in turn depends on the namespace prefix.
I propose to write a method that will create this static XML part, and then we'll just augment it with other XML nodes. It's very useful when you have a lot of operations with XML that contain a static part. The only dynamic data we will have is the dynamic NamespacePrefix and NamespaceUri, which also affect the Root/Header/Body nodes attributes. I also added an empty Header, which isn't necessary in this API but might be useful in other cases.
If you call this method with these parameters:

CreateBaseXMLDoc('soap12', 'http://www.w3.org/2003/05/soap-envelope')
then the result will be like this:
Then I simply create a child element for body node of XML using AddChildElementWithTxtValue(). This method is used to create nested sctrucure of nodes. To add this result XML element to the static XML document, we need to select the body node from the XML Document.
To access the body node of our XML Document, the SelectSingleNode() method is used. We need to specify an XPath parameter that points to the path to the node. Here we have a problem with the correct XPath because of namespaces. If we just specify XPath without correct NameSpaceManager context, the node will not be found. This is a particularly difficult task if we need to process incoming XML that may contain non-obvious namespaces.
To avoid possible problems and for simplicity, I use local-name XPath, which allows you to address the node ignoring any namespace prefixes. The signature of XPath looks like this:

[local-name()="NODE_NAME"]
The final XPath to the body of our document will be

/*[local-name()="Envelope"]/*[local-name()="Body"]
There is an easier way, you can simply remove all namespace before SelectSingleNode(), which is great for parsing incoming XML. Just don't do it with a request XML, or the server won't accept your XML without namespaces. Thanks to Andrei Lungu for reminding me about RemoveNamespaces() method from codeunit "XML DOM Management". Example of usage:

Result := XMLDOMManagement.RemoveNamespaces(Result);
//XPath without namespaces
NewTemperature := GetValueFromXML(Result, '/Envelope/Body/FahrenheitToCelsiusResponse/FahrenheitToCelsiusResult');

var
  XMLDOMManagement: Codeunit "XML DOM Management";
Finally, by accessing the body node we can add a dynamic XML Element to the static part and write the entire XML document to a text variable to send the HTTP request via SendRequest().

BaseXMLDoc.SelectSingleNode(BodyXPathLbl, BodyNode);
BodyNode.AsXmlElement().Add(XMLElem);
BaseXMLDoc.WriteTo(ContentToSend);
Result := SendTemperatureConversionAPIRequest(ContentToSend, ContentType, DictionaryDefaultHeaders);

The server will return an XML response in which we need to read the temperature value in the numbering system we need. Based on the documentation and our knowledge of the local name XPath we are able to read the value of the node on the path, for Celsius it will be:

/*[local-name()="Envelope"]/*[local-name()="Body"]/*[local-name()="FahrenheitToCelsiusResponse"]/*[local-name()="FahrenheitToCelsiusResult"]
To read XML values, I use the GetValueFromXML() function, which converts a text variable to an XML DataType and searches for InnerText by XPath.

NewTemperature := GetValueFromXML(Result, ResultXPath);

procedure GetValueFromXML(Content: Text; pNodePath: Text): Text
var
    XMLRootNode: XmlNode;
    XMLChildNode: XmlNode;
    XMLElem: XmlElement;
begin
    GetRootNode(ConvertTextToXmlDocument(Content), XMLRootNode);

    XMLRootNode.SelectSingleNode(pNodePath, XMLChildNode);
    XMLElem := XMLChildNode.AsXmlElement();
    exit(XMLElem.InnerText());
end;
Final main method for temperature conversion:

procedure Convert(ConvertType: Enum "SDA Temperature Convert Type"; SoapVersion: Enum "SDA SOAP Version"; Temperature: Integer) NewTemperature: Text
var
    DictionaryDefaultHeaders: Codeunit "Dictionary Wrapper";
    BaseXMLDoc: XmlDocument;
    XMLElem: XmlElement;
    BodyNode: XmlNode;
    ResultXPath: Text;
    ContentToSend: Text;
    ContentType: Text;
    Result: Text;
begin
    //Generate base XML request based on SOAP version
    //w3schools temperature converter support SOAP 1.1 and SOAP 1.2
    //depending on the SOAP version, we need to choose the correct namespace
    //SOAPAction is required for SOAP 1.1
    case SoapVersion of
        SoapVersion::soap:
            begin
                BaseXMLDoc := CreateBaseXMLDoc(Format(SoapVersion), SOAP11NamespaceURILbl);
                ContentType := SOAP11XMLContentTypeLbl;
                DictionaryDefaultHeaders.Set('SOAPAction', '');
            end;
        SoapVersion::soap12:
            begin
                BaseXMLDoc := CreateBaseXMLDoc(Format(SoapVersion), SOAP12NamespaceUriLbl);
                ContentType := SOAP12XMLContentTypeLbl;
            end;
    end;

    //Generate body of request XML document in XMLElem variable
    //AddChildElementWithTxtValue() used for append child node to parent (first param)
    //Fill ResultXPath variable based on Convert Type
    //Set related SOAPAction for SOAP 1.1
    case ConvertType of
        ConvertType::"To Celsius":
            begin
                XMLElem := XmlElement.Create('FahrenheitToCelsius', W3BaseNamespaceUriLbl);
                AddChildElementWithTxtValue(XMLElem, 'Fahrenheit', W3BaseNamespaceUriLbl, Format(Temperature));
                ResultXPath := BodyXPathLbl + LocalXPathSeparatorLbl + StrSubstNo(LocalXPathSignatureLbl, 'FahrenheitToCelsiusResponse') +
                                LocalXPathSeparatorLbl + StrSubstNo(LocalXPathSignatureLbl, 'FahrenheitToCelsiusResult');
                if DictionaryDefaultHeaders.ContainsKey('SOAPAction') then
                    DictionaryDefaultHeaders.Set('SOAPAction', W3BaseNamespaceUriLbl + 'FahrenheitToCelsius');
            end;
        ConvertType::"To Farenheit":
            begin
                XMLElem := XmlElement.Create('CelsiusToFahrenheit', W3BaseNamespaceUriLbl);
                AddChildElementWithTxtValue(XMLElem, 'Celsius', W3BaseNamespaceUriLbl, Format(Temperature));
                ResultXPath := BodyXPathLbl + LocalXPathSeparatorLbl + StrSubstNo(LocalXPathSignatureLbl, 'CelsiusToFahrenheitResponse') +
                                LocalXPathSeparatorLbl + StrSubstNo(LocalXPathSignatureLbl, 'CelsiusToFahrenheitResult');
                if DictionaryDefaultHeaders.ContainsKey('SOAPAction') then
                    DictionaryDefaultHeaders.Set('SOAPAction', W3BaseNamespaceUriLbl + 'CelsiusToFahrenheit');
            end;
    end;

    //Append body of request XMLElem to base request XML document
    BaseXMLDoc.SelectSingleNode(BodyXPathLbl, BodyNode);
    BodyNode.AsXmlElement().Add(XMLElem);
    BaseXMLDoc.WriteTo(ContentToSend);

    //Send request XML Document and write result to text variable
    Result := SendTemperatureConversionAPIRequest(ContentToSend, ContentType, DictionaryDefaultHeaders);

    //Read result XML to find result temperature from ResultXPath
    NewTemperature := GetValueFromXML(Result, ResultXPath);

    //Handle error in case of success response status code (200)
    if NewTemperature = ErrorLbl then
        Error(ErrorLbl);
end;
To test-drive the demo, simply search for "Temperature Converter" in the search box.
Source code and application is available on github:
https://github.com/Drakonian/SOAP-demo-API
August 2, 2022