25

I'm using next.js. I have a 3rd party service I need to retrieve a PDF file from. The service requires an API key that I don't want exposed on the client side.

Here are my files

/api/getPDFFile.js ...

  const options = {
    method: 'GET',
    encoding: 'binary',
    headers: {
      'Subscription-Key': process.env.GUIDE_STAR_CHARITY_CHECK_API_PDF_KEY,
      'Content-Type': 'application/json',
    },
    rejectUnauthorized: false,
  };

  const binaryStream = await fetch(
    'https://apidata.guidestar.org/charitycheckpdf/v1/pdf/26-4775012',
    options
  );
  
  return res.status(200).send({body: { data: binaryStream}}); 


pages/getPDF.js

   <button type="button" onClick={() => {
  fetch('http://localhost:3000/api/guidestar/charitycheckpdf',
    {
      method: 'GET',
      encoding: 'binary',
      responseType: 'blob',
    }).then(response => {
      if (response.status !== 200) {
        throw new Error('Sorry, I could not find that file.');
      }
      return response.blob();
    }).then(blob => {
      const url = window.URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.style.display = 'none';
      a.href = url;
      a.setAttribute('download', 'test.pdf');
      document.body.appendChild(a);
      a.click();
      window.URL.revokeObjectURL(url);
    })}}>Click to Download</button>

Clicking the button downloads a file, but when I open it I see the error message, "Failed to load PDF document."

juliomalves
  • 42,130
  • 20
  • 150
  • 146
JoshJoe
  • 1,482
  • 2
  • 17
  • 35
  • @brc-dd I am still getting the same results. I have also tried to find something about express downloads, almost all the articles are referring to downloading static files from the server, not from an API. – JoshJoe Jul 22 '21 at 21:07

2 Answers2

36

You appear to be using node-fetch. So, you can do something like this:

// /pages/api/getAPI.js

import stream from 'stream';
import { promisify } from 'util';
import fetch from 'node-fetch';

const pipeline = promisify(stream.pipeline);
const url = 'https://w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf';

const handler = async (req, res) => {
  const response = await fetch(url); // replace this with your API call & options
  if (!response.ok) throw new Error(`unexpected response ${response.statusText}`);

  res.setHeader('Content-Type', 'application/pdf');
  res.setHeader('Content-Disposition', 'attachment; filename=dummy.pdf');
  await pipeline(response.body, res);
};

export default handler;

Then from client:

// /pages/index.js

const IndexPage = () => <a href="/api/getPDF">Download PDF</a>;
export default IndexPage;

CodeSandbox Link (open the deployed URL in a new tab to see it work)

References:

PS: I don't think much error handling is necessary in this case. If you wish to be more informative to your user you can. But this much code will also work just fine. In case of error the file download will fail showing "Server Error". Also, I don't see a need to create a blob URL first. You can directly download it in your app as the API is on the same origin.


Earlier I had used request, also posting it here in case someone needs it:

import request from 'request';
const url = 'https://w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf';
export default (_, res) => { request.get(url).pipe(res); };
brc-dd
  • 10,788
  • 3
  • 47
  • 67
  • Thank you! Request is deprecated, so I was trying not to use it for this. Your other method worked great. – JoshJoe Jul 23 '21 at 17:52
  • 1
    Your answered inspired me to write my own API endpoint that generates a downloadable CSV file from memory (stream). https://stackoverflow.com/a/70089343/2391795 Thank you! – Vadorequest Nov 24 '21 at 00:15
  • 1
    This works great for smaller files. The issue I'm running into is that Nextjs warns, "API response for /api/download exceeds 4MB. This will cause the request to fail in a future version. https://nextjs.org/docs/messages/api-routes-body-size-limit". Freaks me out a bit to continue with this approach though I really don't see why it's an issue. I'm streaming the data. – crice1988 Feb 16 '22 at 22:51
  • This is for some reason not working for me... Any idea? – Primoz Rome Dec 05 '22 at 13:22
  • @PrimozRome Consider asking a new question with debugging details. We can't tell what's going wrong without looking at some code first. For debugging, might wanna check if handler is being executed and there are no errors during fetching your particular file. – brc-dd Dec 05 '22 at 13:36
  • @brc-dd I opened a new question here -> https://stackoverflow.com/questions/74688722/how-to-download-a-string-returned-from-3rd-party-api-as-xml-file-using-next-js-a – Primoz Rome Dec 05 '22 at 13:50
2

@brc-dd saved me on this issue. One of the things that I had to add was a dynamically generated link element (see var link in the code) that clicks itself once we have the file data from the API. This was very important to get consistent downloads (which I was not getting prior).

My page code that creates the link to download the file looks like:

// the fileProps variable used below looks like {"file_name":"test.png", "file_type":"image/png", "file_size": 748833}
import Button from 'react-bootstrap/Button'
import { toast } from 'react-toastify';

const DataGridCell = ({ filename, filetype, filesize }) => {
    const [objFileState, setFileDownload] = useState({})

    // handle POST request here
    useEffect(() => {
        async function retrieveFileBlob() {
            try {
                const ftch = await fetch( // this will request the file information for the download (whether an image, PDF, etc.)
                    `/api/request-file`,
                    {
                        method: "POST",
                        headers: {
                            "Content-type": "application/json"
                        },
                        body: JSON.stringify(objFileState)
                    },
                )
                const fileBlob = await ftch.blob()

                // this works and prompts for download
                var link = document.createElement('a')  // once we have the file buffer BLOB from the post request we simply need to send a GET request to retrieve the file data
                link.href = window.URL.createObjectURL(fileBlob)
                link.download = objFileState.strFileName
                link.click()
                link.remove();  //afterwards we remove the element  
            } catch (e) {
                console.log({ "message": e, status: 400 })  // handle error
            }
        }

        if (objFileState !== {} && objFileState.strFileId) retrieveFileBlob()   // request the file from our file server

    }, [objFileState])

    // NOTE: it is important that the objFile is properly formatted otherwise the useEffect will just start firing off without warning
    const objFile = {
        "objFileProps": { "file_name": filename, "file_type": filetype, "file_size": filesize }
    }
    return <Button onClick={() => {toast("File download started"); setFileDownload(objFile) }} className="btn btn-primary m-2">Download {filename}</Button>

}

My local NextJs API endpoint (/api/qualtrics/retrieve-file) that the link calls looks like:

/**
 * @abstract This API endpoint requests an uploaded file from a Qualtrics response
 * (see Qualtrics API reference for more info: 
https://api.qualtrics.com/guides/reference/singleResponses.json/paths/~1surveys~1%7BsurveyId%7D~1responses~1%7BresponseId%7D~1uploaded-files~1%7BfileId%7D/get)

 * For this API endpoint the parameters we will be:
 * Param 0 = Survey ID
 * Param 1 = Response ID
 * Param 2 = File ID
 * Param 3 = Header object (properties of the file needed to return the file to the client)
 *
 */
// This is a protected API route
import { getSession } from 'next-auth/client'

export default async function API(req, res) {
    // parse the API query
    const { params } = await req.query  // NOTE: we must await the assignment of params from the request query
    const session = await getSession({ req })
    const strSurveyId = await params[0]
    const strResponseId = await params[1]
    const strFileId = await params[2]
    const objFileProps = JSON.parse(decodeURIComponent(await params[3]))    // file properties
    // this if condition simply checks that a user is logged into the app in order to get data from this API
    if (session) {
        // ****** IMPORTANT: wrap your fetch to Qualtrics in a try statement to help prevent errors of headers already set **************
        try {
            const response = await fetch(
                `${process.env.QUALTRICS_SERVER_URL}/API/v3/surveys/${strSurveyId}/responses/${strResponseId}/uploaded-files/${strFileId}`,
                {
                    method: "get",
                    headers: {
                        "X-API-TOKEN": process.env.QUALTRICS_API_TOKEN
                    }
                }
            );

            // get the file information from the external API
            const resBlob = await response.blob();
            const resBufferArray = await resBlob.arrayBuffer();
            const resBuffer = Buffer.from(resBufferArray);
            if (!response.ok) throw new Error(`unexpected response ${response.statusText}`);

            // write the file to the response (should prompt user to download or open the file)
            res.setHeader('Content-Type', objFileProps.file_type);
            res.setHeader('Content-Length', objFileProps.file_size);
            res.setHeader('Content-Disposition', `attachment; filename=${objFileProps.file_name}`);
            res.write(resBuffer, 'binary');
            res.end();
        } catch (error) {
            return res.send({ error: `You made an invalid request to download a file ${error}`, status: 400 })
        }

    } else {
        return res.send({ error: 'You must sign in to view the protected content on this page...', status: 401 })
    }
}
w. Patrick Gale
  • 1,643
  • 13
  • 22