From 49e7651dcfd947870c6b239616be3c2d82ec8662 Mon Sep 17 00:00:00 2001
From: Jay Vercellone <juanjov@gmail.com>
Date: Sat, 3 Oct 2020 20:02:21 -0700
Subject: [PATCH 1/2] Add exhaustive response option to paginated API's

---
 package-lock.json    |  8 +++++
 package.json         |  1 +
 src/index.test.js    | 72 ++++++++++++++++++++++++++++++++++++++++++++
 src/methods/index.js | 52 ++++++++++++++++++++++++++++++--
 4 files changed, 131 insertions(+), 2 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index d653c32..b32941f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8251,6 +8251,14 @@
         "json-parse-better-errors": "^1.0.1"
       }
     },
+    "parse-link-header": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/parse-link-header/-/parse-link-header-1.0.1.tgz",
+      "integrity": "sha1-vt/g0hGK64S+deewJUGeyKYRQKc=",
+      "requires": {
+        "xtend": "~4.0.1"
+      }
+    },
     "parse-ms": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz",
diff --git a/package.json b/package.json
index e96ccc1..d569064 100644
--- a/package.json
+++ b/package.json
@@ -72,6 +72,7 @@
     "p-map": "^3.0.0",
     "p-wait-for": "^3.1.0",
     "parallel-transform": "^1.1.0",
+    "parse-link-header": "^1.0.1",
     "pump": "^3.0.0",
     "qs": "^6.9.3",
     "rimraf": "^3.0.2",
diff --git a/src/index.test.js b/src/index.test.js
index 062b8a2..9ac5f3c 100644
--- a/src/index.test.js
+++ b/src/index.test.js
@@ -320,6 +320,78 @@ test('Can parse text responses', async t => {
   t.true(scope.isDone())
 })
 
+test('Can retrieve multiple pages', async t => {
+  // Expected responses are arrays, since pagination makes sense in the context
+  // calls that return multiple items:
+  // https://docs.netlify.com/api/get-started/#pagination
+  const expectedResponsePages = [
+    [
+      { id: '1', content: 'page 1' },
+      { id: '2', content: 'page 1' }
+    ],
+    [
+      { id: '3', content: 'page 2' },
+      { id: '4', content: 'page 2' }
+    ],
+    [{ id: '5', content: 'page 3' }]
+  ]
+  const expectedResponse = expectedResponsePages.flatMap(x => x)
+
+  const baseUrl = `${pathPrefix}/sites`
+  const baseUrlFull = `${origin}${baseUrl}`
+  const scope = nock(origin)
+    .persist()
+    .get(baseUrl)
+    .reply(200, expectedResponsePages[0], {
+      Link: `<${baseUrlFull}?page=3>; rel="last", <${baseUrlFull}?page=2>; rel="next"`
+    })
+    .get(`${baseUrl}?page=2`)
+    .reply(200, expectedResponsePages[1], {
+      Link: `<${baseUrlFull}?page=3>; rel="last", <${baseUrlFull}?page=3>; rel="next"`
+    })
+    .get(`${baseUrl}?page=3`)
+    .reply(200, expectedResponsePages[2], {
+      Link: `<${baseUrlFull}?page=3>; rel="last"`
+    })
+    .persist(false)
+
+  const client = getClient()
+  const response = await client.listSites({}, { exhaustive: true })
+
+  t.deepEqual(response, expectedResponse)
+  t.true(scope.isDone())
+})
+
+test('Retrieves the first page if exhaustive is not specified', async t => {
+  // The entire dataset, split into multiple pages.
+  const datasetPages = [
+    [
+      { id: '1', content: 'page 1' },
+      { id: '2', content: 'page 1' }
+    ],
+    [
+      { id: '3', content: 'page 2' },
+      { id: '4', content: 'page 2' }
+    ],
+    [{ id: '5', content: 'page 3' }]
+  ]
+  const expectedResponse = datasetPages[0]
+
+  const baseUrl = `${pathPrefix}/sites`
+  const baseUrlFull = `${origin}${baseUrl}`
+  const scope = nock(origin)
+    .get(baseUrl)
+    .reply(200, expectedResponse, {
+      Link: `<${baseUrlFull}?page=3>; rel="last", <${baseUrlFull}?page=2>; rel="next"`
+    })
+
+  const client = getClient()
+  const response = await client.listSites()
+
+  t.deepEqual(response, expectedResponse)
+  t.true(scope.isDone())
+})
+
 test('Handle error empty responses', async t => {
   const account_id = '8'
   const status = 404
diff --git a/src/methods/index.js b/src/methods/index.js
index f2a5381..c551ac5 100644
--- a/src/methods/index.js
+++ b/src/methods/index.js
@@ -1,4 +1,5 @@
 const fetch = require('node-fetch').default || require('node-fetch') // Webpack will sometimes export default exports in different places
+const parseLinkHeader = require('parse-link-header')
 
 const { getOperations } = require('../operations')
 
@@ -29,12 +30,59 @@ const getMethod = function(method, NetlifyApi) {
 }
 
 const callMethod = async function(method, NetlifyApi, params, opts) {
+  const { exhaustive = false } = opts || {}
   const requestParams = Object.assign({}, NetlifyApi.globalParams, params)
   const url = getUrl(method, NetlifyApi, requestParams)
-  const response = await makeRequestOrRetry({ url, method, NetlifyApi, requestParams, opts })
+  const { parsedResponse, headers } = await retrieveResponse({ url, method, NetlifyApi, requestParams, opts })
+  if (!exhaustive || !Array.isArray(parsedResponse)) {
+    // If the user did not enable the retrieval of all items, or
+    // if the response is a single object/item, then
+    // we can skip the pagination logic.
+    return parsedResponse
+  }
+
+  return await retrieveResponseForNextPages({ method, NetlifyApi, requestParams, opts, parsedResponse, headers })
+}
 
+const retrieveResponse = async function({ url, method, NetlifyApi, requestParams, opts }) {
+  const response = await makeRequestOrRetry({ url, method, NetlifyApi, requestParams, opts })
   const parsedResponse = await parseResponse(response)
-  return parsedResponse
+  return {
+    parsedResponse,
+    headers: response.headers
+  }
+}
+
+const retrieveResponseForNextPages = async function({
+  method,
+  NetlifyApi,
+  requestParams,
+  opts,
+  parsedResponse,
+  headers
+}) {
+  // Responses for each page are accumulated, until finally flattened.
+  const results = [parsedResponse]
+
+  // The API directly provides the link to the next page (if any)
+  // in the `Link` header.
+  let url = getNextPageUrl(headers)
+
+  // For paginated results, we retrieve all the pages for this
+  // method until we exhaust the entire dataset.
+  while (url) {
+    const { parsedResponse, headers } = await retrieveResponse({ url, method, NetlifyApi, requestParams, opts })
+    results.push(parsedResponse)
+    url = getNextPageUrl(headers)
+  }
+
+  return results.flatMap(i => i)
+}
+
+const getNextPageUrl = function(headers) {
+  const linkHeader = headers.get('link') || ''
+  const { next = {} } = parseLinkHeader(linkHeader) || {}
+  return next.url
 }
 
 const getOpts = function({ verb, parameters }, NetlifyApi, { body }, opts) {

From caaa6d5a78e688fd5f380c5ea60fa796e481a137 Mon Sep 17 00:00:00 2001
From: Jay Vercellone <juanjov@gmail.com>
Date: Sat, 3 Oct 2020 21:53:53 -0700
Subject: [PATCH 2/2] Replace usage of flatMap for good 'ol concat

---
 src/index.test.js    | 2 +-
 src/methods/index.js | 8 ++++----
 2 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/src/index.test.js b/src/index.test.js
index 9ac5f3c..84cbb57 100644
--- a/src/index.test.js
+++ b/src/index.test.js
@@ -335,7 +335,7 @@ test('Can retrieve multiple pages', async t => {
     ],
     [{ id: '5', content: 'page 3' }]
   ]
-  const expectedResponse = expectedResponsePages.flatMap(x => x)
+  const expectedResponse = expectedResponsePages.reduce((response, page) => response.concat(page), [])
 
   const baseUrl = `${pathPrefix}/sites`
   const baseUrlFull = `${origin}${baseUrl}`
diff --git a/src/methods/index.js b/src/methods/index.js
index c551ac5..a5522f7 100644
--- a/src/methods/index.js
+++ b/src/methods/index.js
@@ -61,8 +61,8 @@ const retrieveResponseForNextPages = async function({
   parsedResponse,
   headers
 }) {
-  // Responses for each page are accumulated, until finally flattened.
-  const results = [parsedResponse]
+  // Responses for each page are accumulated in a flattened manner.
+  let results = parsedResponse
 
   // The API directly provides the link to the next page (if any)
   // in the `Link` header.
@@ -72,11 +72,11 @@ const retrieveResponseForNextPages = async function({
   // method until we exhaust the entire dataset.
   while (url) {
     const { parsedResponse, headers } = await retrieveResponse({ url, method, NetlifyApi, requestParams, opts })
-    results.push(parsedResponse)
+    results = results.concat(parsedResponse)
     url = getNextPageUrl(headers)
   }
 
-  return results.flatMap(i => i)
+  return results
 }
 
 const getNextPageUrl = function(headers) {