1

Hi I am quite new to docxtemplater but I absolutely love how it works. Right now I seem to be able to generate a new docx document as follows:

const functions = require('firebase-functions');
const admin = require('firebase-admin');
const {Storage} = require('@google-cloud/storage');
var PizZip = require('pizzip');
var Docxtemplater = require('docxtemplater');
admin.initializeApp();
const BUCKET = 'gs://myapp.appspot.com';

exports.test2 = functions.https.onCall((data, context) => {
// The error object contains additional information when logged with JSON.stringify (it contains a properties object containing all suberrors).
function replaceErrors(key, value) {
    if (value instanceof Error) {
        return Object.getOwnPropertyNames(value).reduce(function(error, key) {
            error[key] = value[key];
            return error;
        }, {});
    }
    return value;
}

function errorHandler(error) {
    console.log(JSON.stringify({error: error}, replaceErrors));

    if (error.properties && error.properties.errors instanceof Array) {
        const errorMessages = error.properties.errors.map(function (error) {
            return error.properties.explanation;
        }).join("\n");
        console.log('errorMessages', errorMessages);
        // errorMessages is a humanly readable message looking like this :
        // 'The tag beginning with "foobar" is unopened'
    }
    throw error;
}


let file_name = 'example.docx';// this is the file saved in my firebase storage
const File = storage.bucket(BUCKET).file(file_name);
const read = File.createReadStream();

var buffers = [];
readable.on('data', (buffer) => {
  buffers.push(buffer);
});

readable.on('end', () => {
  var buffer = Buffer.concat(buffers);  
  var zip = new PizZip(buffer);
  var doc;
  try {
      doc = new Docxtemplater(zip);
      doc.setData({
    first_name: 'Fred',
    last_name: 'Flinstone',
    phone: '0652455478',
    description: 'Web app'
});
try {
   
    doc.render();
 doc.pipe(remoteFile2.createReadStream());

}
catch (error) {
    errorHandler(error);
}

  } catch(error) {
      errorHandler(error);
  }

});
});

My issue is that i keep getting an error that doc.pipe is not a function. I am quite new to nodejs but is there a way to have the newly generated doc after doc.render() to be saved directly to the firebase storage?

Kola Ayanwale
  • 105
  • 1
  • 8

1 Answers1

2

Taking a look at the type of doc, we find that is a Docxtemplater object and find that doc.pipe is not a function of that class. To get the file out of Docxtemplater, we need to use doc.getZip() to return the file (this will be either a JSZip v2 or Pizzip instance based on what we passed to the constructor). Now that we have the zip's object, we need to generate the binary data of the zip - which is done using generate({ type: 'nodebuffer' }) (to get a Node.JS Buffer containing the data). Unfortunately because the docxtemplater library doesn't support JSZip v3+, we can't make use of the generateNodeStream() method to get a stream to use with pipe().

With this buffer, we can either reupload it to Cloud Storage or send it back to the client that is calling the function.

The first option is relatively simple to implement:

import { v4 as uuidv4 } from 'uuid';
/* ... */

const contentBuffer = doc.getZip()
      .generate({type: 'nodebuffer'});
const targetName = "compiled.docx";
  
const targetStorageRef = admin.storage().bucket()
  .file(targetName);
await targetStorageRef.save(contentBuffer);

// send back the bucket-name pair to the caller
return { bucket: targetBucket, name: targetName };

However, to send back the file itself to the client isn't as easy because this involves switching to using a HTTP Event Function (functions.https.onRequest) because a Callable Cloud Function can only return JSON-compatible data. Here we have a middleware function that takes a callable's handler function but supports returning binary data to the client.

import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
import corsInit from "cors";

admin.initializeApp();

const cors = corsInit({ origin: true }); // TODO: Tighten

function callableRequest(handler) {
  if (!handler) {
    throw new TypeError("handler is required");
  }
  
  return (req, res) => {
    cors(req, res, (corsErr) => {
      if (corsErr) {
        console.error("Request rejected by CORS", corsErr);
        res.status(412).json({ error: "cors", message: "origin rejected" });
        return;
      }

      // for validateFirebaseIdToken, see https://github.com/firebase/functions-samples/blob/main/authorized-https-endpoint/functions/index.js
      validateFirebaseIdToken(req, res, () => { // validateFirebaseIdToken won't pass errors to `next()`

        try {
          const data = req.body;
          const context = {
            auth: req.user ? { token: req.user, uid: req.user.uid } : null,
            instanceIdToken: req.get("Firebase-Instance-ID-Token"); // this is used with FCM
            rawRequest: req
          };

          let result: any = await handler(data, context);

          if (result && typeof result === "object" && "buffer" in result) {
            res.writeHead(200, [
              ["Content-Type", res.contentType],
              ["Content-Disposition", "attachment; filename=" + res.filename]
            ]);
            
            res.end(result.buffer);
          } else {
            result = functions.https.encode(result);

            res.status(200).send({ result });
          }
        } catch (err) {
          if (!(err instanceof HttpsError)) {
            // This doesn't count as an 'explicit' error.
            console.error("Unhandled error", err);
            err = new HttpsError("internal", "INTERNAL");
          }

          const { status } = err.httpErrorCode;
          const body = { error: err.toJSON() };

          res.status(status).send(body);
        }
      });
    });
  };
})

functions.https.onRequest(callableRequest(async (data, context) => {
  /* ... */

  const contentBuffer = doc.getZip()
      .generate({type: "nodebuffer"});
  const targetName = "compiled.docx";

  return {
    buffer: contentBuffer,
    contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
    filename: targetName
  }
}));

In your current code, there are a number of odd segments where you have nested try-catch blocks and variables in different scopes. To help combat this, we can make use of File#download() that returns a Promise that resolves with the file contents in a Node.JS Buffer and File#save() that returns a Promise that resolves when the given Buffer is uploaded.

Rolling this together for reuploading to Cloud Storage gives:

// This code is based off the examples provided for docxtemplater
// Copyright (c) Edgar HIPP [Dual License: MIT/GPLv3]

import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
import PizZip from "pizzip";
import Docxtemplater from "docxtemplater";

admin.initializeApp();

// The error object contains additional information when logged with JSON.stringify (it contains a properties object containing all suberrors).
function replaceErrors(key, value) {
  if (value instanceof Error) {
    return Object.getOwnPropertyNames(value).reduce(
      function (error, key) {
        error[key] = value[key];
        return error;
      },
      {}
    );
  }
  return value;
}

function errorHandler(error) {
  console.log(JSON.stringify({ error: error }, replaceErrors));

  if (error.properties && error.properties.errors instanceof Array) {
    const errorMessages = error.properties.errors
      .map(function (error) {
        return error.properties.explanation;
      })
      .join("\n");
    console.log("errorMessages", errorMessages);
    // errorMessages is a humanly readable message looking like this :
    // 'The tag beginning with "foobar" is unopened'
  }
  throw error;
}

exports.test2 = functions.https.onCall(async (data, context) => {
  const file_name = "example.docx"; // this is the file saved in my firebase storage
  const templateRef = await admin.storage().bucket()
      .file(file_name);
  const template_content = (await templateRef.download())[0];
  const zip = new PizZip(template_content);

  let doc;
  try {
    doc = new Docxtemplater(zip);
  } catch (error) {
    // Catch compilation errors (errors caused by the compilation of the template : misplaced tags)
    errorHandler(error);
  }

  doc.setData({
    first_name: "Fred",
    last_name: "Flinstone",
    phone: "0652455478",
    description: "Web app",
  });

  try {
    doc.render();
  } catch (error) {
    errorHandler(error);
  }

  const contentBuffer = doc.getZip().generate({ type: "nodebuffer" });

  // do something with contentBuffer
  // e.g. reupload to Cloud Storage
  const targetStorageRef = admin.storage().bucket().file("compiled.docx");
  await targetStorageRef.save(contentBuffer);

  return { bucket: targetStorageRef.bucket.name, name: targetName };
});

In addition to returning a bucket-name pair to the caller, you may also consider returning an access URL to the caller. This could be a signed url that can last for up to 7 days, a download token URL (like getDownloadURL(), process described here) that can last until the token is revoked, Google Storage URI (gs://BUCKET_NAME/FILE_NAME) (not an access URL, but can be passed to a client SDK that can access it if the client passes storage security rules) or access it directly using its public URL (after the file has been marked public).

Based on the above code, you should be able to merge in returning the file directly yourself.

samthecodingman
  • 23,122
  • 4
  • 30
  • 54
  • 1
    hi @samthecodingman I really appreciate that. It keeps returning an error `Unhandled error TypeError: Cannot read property 'toLowerCase' of undefined` it's the line `const zip = new PizZip(buffer);` that is returning the error – Kola Ayanwale Jun 16 '21 at 06:21
  • same issue I'm having. Thanks I followed your code (i hope you dont mind) and got the same error message – Deji James Jun 16 '21 at 18:33
  • 1
    @KolaAyanwale if you get the error message `Cannot read property 'toLowerCase' of undefined`this means that you didn't pass a valid object to pizzip. It is easiest to use a buffer for the input. I think it is because readStreamToBuffer returns a promise. If you write `const buffer = await readStreamToBuffer(storageRef.createReadStream());` it will work – edi9999 Jun 16 '21 at 19:58
  • Good catch @edi9999, I indeed forgot the `await`. – samthecodingman Jun 17 '21 at 02:35
  • Wo thanks. @samthecodingman !. I got an error `Exception from a finished function: TypeError: Assignment to constant variable.` Is that just me? – Deji James Jun 18 '21 at 22:07
  • @DejiJames you must have tried putting the function in a variable and then called it within `functions.https.onRequest` right? The error i keep getting is a cors error. No matter the name I try to call (even incorrect function names) returns this error: `Access to fetch at 'https://us-central1-mpcwapp.cloudfunctions.net/test' from origin 'http://localhost:8383' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled` – Kola Ayanwale Jun 19 '21 at 12:07
  • 1
    @DejiJames Found the line causing that error and fixed it. I was using `const chunkArray` when it should have been `let chunkArray`. But because I've updated it to use `File#download()` and File#save()` to further simplify the function, it wasn't needed anymore. – samthecodingman Jun 19 '21 at 18:18
  • @KolaAyanwale Let me know how you get on with the new changes. – samthecodingman Jun 19 '21 at 18:18
  • 1
    @samthecodingman honestly you are a lifesaver man. Thanks so much for the help. – Kola Ayanwale Jun 19 '21 at 19:06
  • Thanks guys. I am working on something similar but I'm trying to get the url to the file returned to the client side (with the access code). I keep getting an error `Unhandled error TypeError: targetStorageRef.getDownloadURL is not a function`. I thought the line `// send back a URL and the bucket-name pair to the caller return { url: location, bucket: targetStorageRef.bucket.name, name: targetName };` was exactly what I needed but I keep getting an error. Is that happening to anyone else? – Deji James Jun 23 '21 at 00:38
  • @DejiJames Unfortunately that was something left over from bringing in code from one of my web projects that uses the Client SDK. The process of creating a URL using the Admin SDK is a little different because it depends on what you are using the file for and how you are securing access to your function. For the OP's use case, a short-lived signed URL would be suitable. See the closing paragraph above and [this answer](https://stackoverflow.com/a/56010225/3068190) for an overview of the different types. – samthecodingman Jun 23 '21 at 12:04