Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to download a file on Next.js using an API route

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."

like image 869
JoshJoe Avatar asked Jul 22 '21 19:07

JoshJoe


People also ask

How do I download a file using API?

In this article, I will use a demo Web API application in ASP.NET Core to show you how to transmit files through an API endpoint. In the final HTML page, end users can left-click a hyperlink to download the file or right-click the link to choose “ Save Link As ” in the context menu and save the file.

How do I download a file using Javascript?

To ask the browser to download a file it can render, use the following header: Content-Disposition: attachment; filename="downloaded. pdf" (you can of course customize the filename as you need).

How do Nextjs API routes work?

API routes provide a solution to build your API with Next. js. Any file inside the folder pages/api is mapped to /api/* and will be treated as an API endpoint instead of a page . They are server-side only bundles and won't increase your client-side bundle size.


2 Answers

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:

  • API Routes | Next.js
  • Streams | node-fetch
  • How to send a pdf file from Node/Express app to the browser
  • <a>: The Anchor element | MDN

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); };
like image 114
brc-dd Avatar answered Nov 15 '22 06:11

brc-dd


@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 })
    }
}
like image 21
w. Patrick Gale Avatar answered Nov 15 '22 06:11

w. Patrick Gale