Skip to Content
Technical Articles
Author's profile photo Lachlan Edwards

Returning a Buffer/File from CAP

Today, I will outline the steps our team took to return a File from CAP under some unique circumstances. While this tutorial follows the story of how to return a raw PDF File created on-the-fly in CAP on NodeJS, the underlying logic pertains to any file whether it has to be retrieved from an external service, the file system or (like our case) created in real-time.

We were, as I’ve mentioned, using CAP on NodeJS and trying to return a file we created in JSPDF. It sounds easy… initially we settled for asking JSPDF to produce a BASE64 String (as apart of a built-in feature included in JSPDF) and returned that as a String to the client before one of our customers pointed out in testing that our implemented solution didn’t work on iOS devices. I’ll clarify that the BASE64 String was returned as a DataURI String from our CAP server. On most desktop browsers we were able to create an <a> element, link to the DataURI String and include the HTML download attribute, fire .click() on that element and the file would download (as per StackOverflow’s recommendations). 

let link = document.createElement('a');
link.href = data_uri_string;
link.download = `${file_name}.pdf`;
link.click();

While this worked a treat on Chrome, Firefox and even Safari on Windows and MacOS, our customer uses iPad devices in the field and Safari on iOS didn’t like this. We soon learnt that Safari on iOS doesn’t like a lot of things… we tried using JavaScript’s “window.open” to open the DataURI String in a new tab, in the same tab, and even using the reference object returned by “window.open” to construct a temporary page that included a real, rendered <a> link that the user would click on to download the file (so the browser would attribute a user-action to us trying to download a file). Safari hated all of this, it would not open the file no matter how hard we tried and the results on Google and StackOverflow confirmed our suspicions that Safari’s strict security requirements on iOS made this overly difficult and in a lot of cases, impossible.

I’d say you are wondering why we didn’t just return the File from CAP in the first-place? Well… as far as we could tell CAP has a very specific way it wants us to handle files… it wants to retrieve them from a database. We turned the SAP Community upside down trying to find a solution but all existing blog posts and CAP documentation only had examples for creating a service, storing the file in the database and using OData to retrieve the “content” of the file from the database. It seems unnecessary for us to create this file, store it in the database, provide a limited window for the client to download the file and then delete it from the database. We weren’t provisioned the resources to store the file and even if we did have the resources the dynamic nature of our Application would make this a taxing process – I won’t divulge too many details but creating the file in real-time was our only real option.

We tried to specify entities that returned LargeBinaries and MediaTypes but CAP always parsed our File into some-kind of encoded String, wrapped it in an OData Response and set the Content-Type header to application/json – which the browser cannot parse as a File.

I found this super frustrating, since CAP was built on ExpressJS I should easily be able to send something without OData… but, and please correct me in the comments if I am wrong, it doesn’t seem CAP is capable of this.

Edit: David has mentioned in the comments that it is possible to register an Express handler inside the CDS handler for the ‘bootstrap’ event. While this was known at the time, as David mentioned it doesn’t implement CAP’s security – a dealbreaker for us in this circumstance.

I had a bit of a eureka moment at this point and too was delighted to see the in the CAP documentation that the underlying Express Response was accessible via the “req” parameter passed to event handlers. I wondered if I could steal the response away from CAP and send my own response as if CAP and it’s underlying middleware didn’t exist for this endpoint. 

I could! If I didn’t return anything from the event handler, or call any helper methods like req.reply, but instead called the Express .send() method, I was able to take control of the response and have Express fulfil the request rather than CAP. 

Now, JSPDF is meant to be used as a client-side library so a lot of it’s returned types only apply to JavaScript API’s available in the browser: ArrayBuffer and Blob doesn’t exist in Node… however Node does have the Buffer type. It’s similar to ArrayBuffer and easily able to take an ArrayBuffer and convert it into a Buffer.

let array_buffer = generate_pdf(); 
let buffer = Buffer.from(array_buffer);

If you’ve ever read the Express documentation, you’d know Express handles Buffer’s natively and you can pass one to the “res.send()” method in Express and it will do the rest. I discovered however at this point that CAP had already set the Content-Type header as application/json and in the browser I was receiving the encoded String the browser parsed as JSON. No worries… we have access to the response object so we can just set the Content-Type header to “application/pdf” before we call “res.send()”.

It’s worth noting here, as noted in the CAP documentation, that CAP stores a reference to the Express response object in “req._.res” (where req is the request parameter passed to your handler event).

 

req._.res.set(‘Content-Type’, ‘application/pdf’);

Once we had set the content type, all we had to do was return the Buffer:

req._.res.send(buffer);

We have to ensure that the method ends here… the server will throw an error if CAP tries to manipulate the request after it’s been sent… so no return statement or req.reply() statements can follow. 

All together, our server-side code looked like this:

NOTE: We did this inside the event handler for a “CAP function” that was declared in our CDS file – this way we can ensure that all of our security configurations remain in-tact and we keep our API consistent.

srv.on(‘generate_pdf’, async req => {
    try {
        let array_buffer = generate_pdf();
        let buffer = Buffer.from(array_buffer);
        req._.res.set('Content-Type', 'application/pdf');
        req._.res.send(buffer);
    } catch (error) {
        req.reject(400, error);
    }
});

We then used UI5’s built in device model to determine whether or not the client was being accessed from a mobile Safari device, constructed the URL and called JavaScript’s window.open API to open the file in a new tab, calling the generate_pdf() back-end function and displaying a PDF the user was now able to see and save. 

if (sap.ui.Device.browser.safari && sap.ui.Device.browser.mobile) {
    let sPath =“/generate_pdf(…)";
    let sOrigin = window.location.origin;
    let sServiceUrl = oModel.sServiceUrl;
    window.open(`${sOrigin}${sServiceUrl}${sPath}`, "_blank");
} else {
    let sPath =“/generate_pdf_alt(…)";
    let oBindingContext = oModel.createBindingContext("/");
    let oOperation = oModel.bindContext(sPath, oBindingContext);
    oOperation.execute().then(() => {
        let response = oOperation.getBoundContext().getObject();
        let data_uri_string = response.value;
        let link = document.createElement('a');
        link.href = data_uri_string;
        link.download = `${file_name}.pdf`;
        link.click();
    }).catch((error) => {
        console.error(error);
    });
}

Of course, this solution works as long as you have a Buffer (or have something that can be converted into a Buffer). It doesn’t need to come from JSPDF, it can come from an external source or even the File System. It’s easy to translate files into a Buffer in NodeJS – Buffer.from() is particularly powerful but there’s also plenty of NPM packages available for more obscure cases.

I hope this blog post was informative and if you have any questions feel free to leave a comment!

If you’re interested in learning more about CAP there is a Q&A available here >>
Or you can view the SAP Community topic on CAP here: https://community.sap.com/topics/cloud-application-programming

Assigned Tags

      9 Comments
      You must be Logged on to comment or reply to a post.
      Author's profile photo David Kunz
      David Kunz

      Hi Lachlan,

      I found this super frustrating, since CAP was built on ExpressJS I should easily be able to send something without OData… but, and please correct me in the comments if I am wrong, it doesn’t seem CAP is capable of this.

      It is possible to register express handlers in your CAP app:

      // custom server.js
      const cds = require('@sap/cds')
      cds.on('bootstrap', app => {
        // register handlers/middlewares using the express app
      })
      module.exports = cds.server

      More info here: https://cap.cloud.sap/docs/node.js/cds-server#custom-server-js

      Note: As it's a custom handler, you're responsible for its security.

       

      Best regards,
      David

      Author's profile photo Lachlan Edwards
      Lachlan Edwards
      Blog Post Author

      Hi David,

      I did discover this at some point but we use the CDS security (incl. XSUAA integration) and didn't want to have to re-implement our security for specific endpoints (as you've noted).

      Thanks! 😊

      Author's profile photo Vikas Lamba
      Vikas Lamba

      Hi David,

      I have a function import implementation in CAP's nodejs runtime where the function processes few tasks that run for long and I want to send back some updates to the user via req.res.send(""). Our API is being simply consumed in POSTMAN to do some batch works behind the scenes  and its a valid use case 🙂

      But as Lachlan pointed out doing this will essentially interfere with CAP's processing unless I terminate the request right after I execute the send() method.

      Is there a workaround where I can send multiple responses back piggybacking on the underlying HTTP response object without needing to terminate the request and won't end up getting this error:

      [cds] - {
      message: 'Headers already sent',
      id: '1156639',
      level: 'WARNING',
      timestamp: 1617824793400
      }
      [cds] - InternalServerError: Response data was already sent while there is still data available in response buffer
      at SendResponseCommand.execute (/home/user/projects/IAC-Reporting/node_modules/@sap/cds-runtime/lib/cds-services/adapter/odata-v4/okra/odata-server/invocation/SendResponseCommand.js:30:13)
      at CommandExecutor._execute (/home/user/projects/IAC-Reporting/node_modules/@sap/cds-runtime/lib/cds-services/adapter/odata-v4/okra/odata-server/invocation/CommandExecutor.js:74:17)
      at /home/user/projects/IAC-Reporting/node_modules/@sap/cds-runtime/lib/cds-services/adapter/odata-v4/okra/odata-server/invocation/CommandExecutor.js:84:18
      at SerializingCommand.execute (/home/user/projects/IAC-Reporting/node_modules/@sap/cds-runtime/lib/cds-services/adapter/odata-v4/okra/odata-server/invocation/SerializingCommand.js:39:14)
      at CommandExecutor._execute (/home/user/projects/IAC-Reporting/node_modules/@sap/cds-runtime/lib/cds-services/adapter/odata-v4/okra/odata-server/invocation/CommandExecutor.js:74:17)
      at /home/user/projects/IAC-Reporting/node_modules/@sap/cds-runtime/lib/cds-services/adapter/odata-v4/okra/odata-server/invocation/CommandExecutor.js:84:18
      at SetStatuscodeCommand.execute (/home/user/projects/IAC-Reporting/node_modules/@sap/cds-runtime/lib/cds-services/adapter/odata-v4/okra/odata-server/invocation/SetStatuscodeCommand.js:46:7)
      at CommandExecutor._execute (/home/user/projects/IAC-Reporting/node_modules/@sap/cds-runtime/lib/cds-services/adapter/odata-v4/okra/odata-server/invocation/CommandExecutor.js:74:17)
      at /home/user/projects/IAC-Reporting/node_modules/@sap/cds-runtime/lib/cds-services/adapter/odata-v4/okra/odata-server/invocation/CommandExecutor.js:84:18
      at SetResponseHeadersCommand.execute (/home/user/projects/IAC-Reporting/node_modules/@sap/cds-runtime/lib/cds-services/adapter/odata-v4/okra/odata-server/invocation/SetResponseHeadersCommand.js:87:5) {
      id: '1156639',
      level: 'ERROR',
      timestamp: 1617824793401
      }

       

      Cheers,

      Vikas

      Author's profile photo Carl Önnheim
      Carl Önnheim

      Very nice Lachlan Edwards , thanks for sharing! This works fine for us too, I am also getting these in the logs.

      [cds] - {
        message: 'Headers already sent',
        id: '1505591',
        level: 'WARNING',
        timestamp: 1628803119754
      }

      but the outcome is what we need.

      David Kunz, does SAP have a view on this, can we expect this pattern to work across releases or is it tampering with items too far behind the scenes? I really like the ability to connect document generation with the authorization model in this way.

       

      Thanks!

      //Carl

      Author's profile photo David Kunz
      David Kunz

      Hi Carl,

      Yes, it seems like a valid use case, however CAP doesn't support this atm. Only via plain express where you're responsible for authorization. I can imagine that inside CAP it could get quite difficult to support such a feature (as we're also examining/manipulating responses).

      I think the better, and more general solution, would be that CAP would make authorization handling easier in plain express handlers. Then you're completely unrestricted in what you want to do without the hassle of dealing with authorization. I will discuss this internally.

      Best regards,
      David

      Author's profile photo Sebastian Van Syckel
      Sebastian Van Syckel

      Hi Carl,

      CAP will not offer any API for this. However, role-based authentication for custom middlewares is not too complicated with @sap/xssec, as shown in the (untested) snippet below. The strategy extension is only done to give it a different name and avoid any potential problems due to name clash, as CAP will also register a JWTStrategy.

      Best,
      Sebastian

      const cds = require('@sap/cds')
      
      const passport = require('passport')
      const JWTStrategy = require('@sap/xssec').JWTStrategy
      class MyJWTStrategy extends JWTStrategy {
        constructor(...args) {
          super(...args)
          this.name = 'MyJWTStrategy'
        }
      }
      const xsenv = require('@sap/xsenv')
      
      cds.on('bootstrap', app => {
        passport.use('/<path>', new MyJWTStrategy(xsenv.getServices({xsuaa:{tag:'xsuaa'}}).xsuaa))
        app.use('/<path>',
          passport.initialize(),
          passport.authenticate('MyJWTStrategy', { session: false }),
          (req, res, next) => {
            if (!req.authInfo) return res.status(401).end()
            if (!req.authInfo.checkLocalScope('<role>')) return res.status(403).end()
            next()
          }
        )
        app.use('/<path>', (req, res, next) => {
          // whatever needs to be done to serve the request
        })
      })
      
      Author's profile photo Carl Önnheim
      Carl Önnheim

      Fantastic, looks straight forward enough. Thanks for the quick responses!

      //Carl

      Author's profile photo Pieter Janssens
      Pieter Janssens

      Hi Sebastian,

      Thanks for providing this sample. I first had an issue trying to get it working as it kept saying 'MyJWTStrategy' was unknown in the call to passport.authenticate(). I saw that passport.authenticate also supports passing the strategy object and that's what worked for me.

      I have posted my slightly altered implementation of your sample here: https://blogs.sap.com/2021/10/14/create-authenticated-endpoints-in-cap-that-serve-any-type-of-response/

      Best regards,

      Pieter

      Author's profile photo Shaun Oosthuizen
      Shaun Oosthuizen

      Hi Lachlan,

      Thanks for writing up this alternative approach. I'm experiencing the same issue mentioned in some of the other replies. The file downloads successfully on the first attempt, but crashes the service whenever the request is repeated, which makes it unviable for us.

      Interesting idea though.

      Thanks,

      Shaun