Resolving datajs response header access errors for Safari on Mac OS X

Table of contents:


1. Introduction

Recently I discovered a bug in one of my SAPUI5 applications which I implemented for a client. However, the bug only occurred on Safari running on Mac OS X (Mac OS X 10.9.4, Safari 7.0.6). Back at home I debugged very deep into SAPUI5 and datajs.js (the OData JavaScript library used by SAPUI5). Here are my findings and a solution for fixing the issues I was facing with SAPUI5 1.18.6 and the corresponding datajs.js version. At the time of writing this tutorial the latest version of datajs.js was version 1.1.2, which holds all the issues described in this tutorial.

2. Scenario Description

I have an SAPUI5 application which executes OData calls to SAP Gateway 2.0. My CREATE_ENTITY calls are validated on the backend inside my ABAP code. Potential errors are returned to the frontend (including a corresponding HTTP Status code). As an example let’s assume we have modeled an Employee entity Employees Set called Employees on the backend which has exactly three properties: employeeId, firstName and lastName. firstName and lastName have to be filled, else the backend rejects the creation of the entity and returns the error information to the client.

Our SAPUI5/JavaScript code to create an entity would look something like this:

Code Box 1:
var oNewEmployee, oModel;

oNewEmployee = {
    firstName  : "",
    lastName : ""
};
oModel = new sap.ui.model.odata.ODataModel("/sap/opu/odata/sap/employee.svc/", true);
oModel.create('/Employees', oNewEmployee, null,
    function(oData, oResponse){
        sap.ui.commons.MessageBox.show("Employee was created",
            sap.ui.commons.MessageBox.Icon.SUCCESS,
            "SUCCESS",
            [sap.ui.commons.MessageBox.Action.OK], null, sap.ui.commons.MessageBox.Action.OK
        );
    },
    function(oError){
        var oErrorData, oResponse, sContentType;

        oErrorData   = oError.oData;
        oResponse    = oErrorData.response;
        sContentType = oResponse.headers['content-type'];       //FIXME This causes an error on OS X Safari!
        //…
    }
);

3. Accessing the header Content-Type

Inside the error handler (see code snippet above) I need to access the Content-Type of the Response-Header which we receive from the SAP Gateway. Based on that information I can decide what to do next (not relevant here). The code above works perfectly on all browsers with one exception: Mac OS X Safari. While oResponse.headers['content-type'] is executed just fine on all the other browsers Safari on Mac OS X throws an exception. I have found out that oResponse.headers['Content-Type'] works on Safari, however, I was confused because I know that according to the HTTP specs header field names are case-insensitive (see http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html). That means when you access your header fields in either a case-sensitive or case-insensitive way you should always get the same result. Here is a jQuery example to prove it (I am too lazy for plain old JavaScript):

Code Box 2:
$.ajax({
    type: "GET",
    url: "/sap/opu/odata/sap/employee.svc/Employees('12345')?$format=json",
    success: function(data, textStatus, request){
        alert(request.getResponseHeader('Content-type'));
    },
    error: function (request, textStatus, errorThrown) {
        alert(request.getResponseHeader('Content-type'));
    }
});

In the code above you could also replace Content-type with for example conTent-tYpe - the result will still be the same. Now let’s see what actually happens in Google Chrome and Safari if you call request.getAllResponseHeaders() on a Mac for the same URL using jQuery:

Code Box 3:
$.ajax({
    type: "GET",
    url: "/sap/opu/odata/sap/employee.svc/Employees('12345')?$format=json",
    success: function(data, textStatus, request){
        alert(request.getAllResponseHeaders());
    },
    error: function (request, textStatus, errorThrown) {
        alert(request.getAllResponseHeaders());
    }
});

The following screenshots illustrate the result for Chrome and Safari on MAC OS X as well as for Internet Explorer 9 on Windows 7:

Chrome and Safari on MAC OS X - getAllResponseHeaders()
Chrome - getAllResponseHeaders() Safari - getAllResponseHeaders()
IE 9 on Windows 7 - getAllResponseHeaders()
IE - getAllResponseHeaders()

Can you already see the difference in the screenshots above? As I have debugged I found out that I receive lower-case header fields from the SAP Gateway. However, in the debugging tools of Max OS X Safari I can see that Safari seems to change this. For example, this is what I see in the Dev Tools of Chrome (left) and Safari (right) on my Mac for the same OData Request:


Chrome and Safari on MAC OS X - Response Headers from Dev Tools
Chrome on MAC OS X - Response Headers from Dev Tools Safari on MAC OS X - Response Headers from Dev Tools

So it seems that Safari changes header field names to something that is “probably” better readable for human beings, i.e. 'content-type' is changed to 'Content-Type'. Let’s keep this in mind for later.

4. Debugging SAPUI5 and datajs.js

After debugging SAPUI5 down to the corresponding datajs.js OData calls I have found some lines of code that are suspicious. First of all, in datajs.js SAP has changed the original code here and there, i.e. the function readResponseHeaders has been modified by SAP. This function is actually from the datajs.js library and it is called to extract the headers of an Ajax response and to put them into a map (which is an associative array). Have a look into datajs.js to see where readResponseHeaders is called. As you will see in the datajs.js code the array filled by readResponseHeaders is passed into a response object which is then actually forwarded in the chain to my SAPUI5 error handler:

Code Box 4:
//somewhere in datajs.js
//…
var headers = [];
readResponseHeaders(xhr, headers);

var response = { requestUri: url, statusCode: statusCode, statusText: statusText, headers: headers, body: xhr.responseText };
//…

Now let’s return to the finding that Safari changes 'content-type' to 'Content-Type'. In datajs.js I have found a handy function, which normalizes the headers:

Code Box 5:
//daja.js
var normalHeaders = {
    "accept": "Accept",
    "content-type": "Content-Type",
    "dataserviceversion": "DataServiceVersion",
    "maxdataserviceversion": "MaxDataServiceVersion"
};

var normalizeHeaders = function (headers) {
    /// <summary>Normalizes headers so they can be found with consistent casing.</summary>
    /// <param name="headers" type="Object">Dictionary of name/value pairs.</param>

    for (var name in headers) {
        var lowerName = name.toLowerCase();
        var normalName = normalHeaders[lowerName];
        if (normalName && name !== normalName) {
            var val = headers[name];
            delete headers[name];
            headers[normalName] = val;
        }
    }
};

5. Possible solutions

I don’t exactly know why the function normalizeHeaders(headers) is not called when creating the response object (i.e. see Code Box 4 above). But what I know is that we have the following options to fix the Mac OS X Safari issue (choose the one you prefer):

5.1. Change datajs.js by adding a call to normalizeHeaders(headers)

Code Box 7:
//…
var headers = [];
readResponseHeaders(xhr, headers);
normalizeHeaders(headers);

var response = { requestUri: url, statusCode: statusCode, statusText: statusText, headers: headers, body: xhr.responseText };
//…
Make sure to change the access to the 'Content-Type' header like this in your Error Handler:
Code Box 8:
sContentType = oResponse.headers['Content-Type'];

5.2. Change SAPUI5 globally to have a global fix for all OData calls (sap.ui.model.odata.ODataModel.js)

First add the following:
Code Box 9:
//implementation is copied from datajs.js (we could also do it without prototype)
sap.ui.model.odata.ODataModel.prototype._normalHeaders = {
    "accept": "Accept",
    "content-type": "Content-Type",
    "dataserviceversion": "DataServiceVersion",
    "maxdataserviceversion": "MaxDataServiceVersion"
};

sap.ui.model.odata.ODataModel.prototype._normalizeHeaders = function(headers){ 
    for (var name in headers) {
        var lowerName = name.toLowerCase();
        var normalName = this._normalHeaders[lowerName];
        if (normalName && name !== normalName) {
            var val = headers[name];
            delete headers[name];
            headers[normalName] = val;
        }
    }
};
Then search for
Code Box 9:
function _handleError(oError) {
and add/change the following code
Code Box 10:
if (fnError) {
    this._normalizeHeaders(oError.response.headers);
    fnError(oError);
}
Make sure to change the access to the 'Content-Type' header like this in our Error Handler:
Code Box 11:
sContentType = oResponse.headers['Content-Type'];

5.3. Add a fix to our own Error Handler for the “Create Employee” request

Code Box 12:
function(oError){
    //implementation is copied from datajs.js (we could also do it without prototype)
    var oErrorData, oResponse, aHeaders, name, sContentType, normalHeaders, lowerName, normalName, val;
       
    oErrorData     = oError.oData;
    oResponse      = oErrorData.response;
    aHeaders       = oResponse.headers
    normalHeaders = {
        "accept": "Accept",
        "content-type": "Content-Type",
        "dataserviceversion": "DataServiceVersion",
        "maxdataserviceversion": "MaxDataServiceVersion"
    };
    for (name in aHeaders) {
        lowerName = name.toLowerCase();
        normalName = normalHeaders[lowerName];
        if (normalName && name !== normalName) {
            val = aHeaders [name];
            delete aHeaders [name];
            aHeaders [normalName] = val;
        }
    }
    oErrorData     = oError.oData;
    oResponse      = oErrorData.response;
    sContentType   = aHeaders['Content-Type'];       //This works now even in Max OS X Safari
    //…
}

6. Learnings

I wrote this tutorial to describe what can go wrong if you pass response headers through an own array in your public APIs. To make it short, the learnings are:

  • Be careful when passing headers through an array in your public APIs
  • Passing headers as an array means:
    • Memory consumption is slightly increased, because you need to loop through request.getAllResponseHeaders() and append the headers of your choice to your new array. Even performance is slightly decreased because of that, however, memory and performance might not convince all of you...
    • You have to decide how you want the keys of your associative array to look like, i.e. "Content-Type", or "content-type" or even something totally different? Or maybe even both? What ever you decide, your decision has both benefits and drawbacks, maybe most importantly: It could lead to confusion.
    • The inconsistencies compared to the HTTP specs could lead to confusion and even to bugs inside code which uses your public APIs
  • It is better to use an abstraction layer, i.e. by passing a proxy object that forwards header access calls directly to request.getResponseHeader(passedString)
Comments
Fixed in Version 1.26.4 (January 2015)
posted by Nabi Zamani
Thu Apr 23 12:11:59 UTC 2015
I reported this error on Oct 20, 2014:
- datajs: https://datajs.codeplex.com/workitem/1355
- OpenUI5: https://github.com/SAP/openui5/issues/194

While the OpenUI5 guys have fixed the bug it seems that nothing happend in datajs. It seems that datajs is not really maintained anymore, so using Apache Olingo (http://olingo.apache.org/) should be considered by anyone who is looking for a JavaScript based OData API....