Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 111 additions & 54 deletions designer/server/src/routes/admin/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,6 @@ export default [
* @param {FormMetadata} metadata
* @param {string} token
* @param {ReturnType<Archiver>} archive
* @returns {Promise<boolean>} true if successful, false if failed
*/
async function processForm(metadata, token, archive) {
// Add metadata first under {id}/metadata.json
Expand All @@ -157,7 +156,6 @@ async function processForm(metadata, token, archive) {
token
)
appendFormDefinitionsToArchive(metadata.id, archive, definition)
return true
}

/**
Expand All @@ -173,67 +171,25 @@ async function downloadAllFormsAsZip(request, responseToolkit) {

try {
const startedAt = performance.now()
const { archive, stream } = createArchiveStream()
const response = createZipResponse(responseToolkit, stream)

const stream = new PassThrough()
// Create archive
const archive = Archiver('zip', { zlib: { level: 9 } })
attachArchiveEventHandlers(archive, stream, request)

// Pipe archive to PassThrough stream
archive.pipe(stream)

// Set headers
const response = responseToolkit.response(stream)
response.header('Content-Type', 'application/zip')
response.header('Content-Disposition', 'attachment; filename="forms.zip"')

// Handle archive events
archive.on('warning', (/** @type {Error} */ err) => {
request.logger.warn(
err,
`[downloadAllForms] Archive warning - ${getErrorMessage(err)}`
)
})

archive.on('error', (/** @type {Error} */ err) => {
request.logger.error(
err,
`[downloadAllForms] Archive error - ${getErrorMessage(err)}`
)
stream.destroy(err)
})

// 5 promises at a time
const concurrency = 5
let totalForms = 0
let batch = []

// Process forms as they're yielded from the generator
for await (const metadata of forms.listAll(token)) {
totalForms++
batch.push(metadata)
// Process in batches
if (batch.length >= concurrency) {
await Promise.all(
batch.map((formMetadata) => processForm(formMetadata, token, archive))
)
batch = []
}
}
const { totalForms, manifestEntities } = await processAllForms(
token,
archive
)

// Process remaining forms in batch
if (batch.length > 0) {
await Promise.all(
batch.map((formMetadata) => processForm(formMetadata, token, archive))
)
}
// no forms, 404 response
if (totalForms === 0) {
request.logger.warn('[downloadAllForms] No forms found for download')
return responseToolkit
.response({ message: 'No forms available to download' })
.code(StatusCodes.NOT_FOUND)
}

appendManifestToArchive(archive, totalForms, manifestEntities)

request.logger.info(
{
totalForms,
Expand All @@ -245,7 +201,6 @@ async function downloadAllFormsAsZip(request, responseToolkit) {
await archive.finalize()

const durationMs = performance.now() - startedAt
// Publish audit event
await publishFormsBackupRequestedEvent(user, totalForms, durationMs)
return response
} catch (err) {
Expand All @@ -262,6 +217,108 @@ async function downloadAllFormsAsZip(request, responseToolkit) {
}
}

/**
* Create archive and stream
* @returns {{ archive: ReturnType<Archiver>, stream: PassThrough }}
*/
function createArchiveStream() {
const stream = new PassThrough()
const archive = Archiver('zip', { zlib: { level: 9 } })
archive.pipe(stream)
return { archive, stream }
}

/**
* Build a response with zip headers
* @param {ResponseToolkit<{ Payload: { action: string; }; }>} responseToolkit
* @param {PassThrough} stream
*/
function createZipResponse(responseToolkit, stream) {
const response = responseToolkit.response(stream)
response.header('Content-Type', 'application/zip')
response.header('Content-Disposition', 'attachment; filename="forms.zip"')
return response
}

/**
* Attach archive event handlers
* @param {ReturnType<Archiver>} archive
* @param {PassThrough} stream
* @param {Request<{ Payload: { action: string } }>} request
*/
function attachArchiveEventHandlers(archive, stream, request) {
archive.on('warning', (/** @type {Error} */ err) => {
request.logger.warn(
err,
`[downloadAllForms] Archive warning - ${getErrorMessage(err)}`
)
})

archive.on('error', (/** @type {Error} */ err) => {
request.logger.error(
err,
`[downloadAllForms] Archive error - ${getErrorMessage(err)}`
)
stream.destroy(err)
})
}

/**
* Process all forms and append metadata/definitions to archive
* @param {string} token
* @param {ReturnType<Archiver>} archive
* @returns {Promise<{ totalForms: number, manifestEntities: { id: string, title: string, slug: string }[] }>}
*/
async function processAllForms(token, archive) {
const concurrency = 5
let totalForms = 0
let batch = []
/** @type {{ id: string, title: string, slug: string }[]} */
const manifestEntities = []

for await (const metadata of forms.listAll(token)) {
totalForms++
manifestEntities.push({
id: metadata.id,
title: metadata.title,
slug: metadata.slug
})
batch.push(metadata)
if (batch.length >= concurrency) {
await Promise.all(
batch.map((formMetadata) => processForm(formMetadata, token, archive))
)
batch = []
}
}

if (batch.length > 0) {
await Promise.all(
batch.map((formMetadata) => processForm(formMetadata, token, archive))
)
}

return { totalForms, manifestEntities }
}

/**
* Append manifest.json to archive
* @param {ReturnType<Archiver>} archive
* @param {number} totalForms
* @param {{ id: string, title: string, slug: string }[]} manifestEntities
*/
function appendManifestToArchive(archive, totalForms, manifestEntities) {
const manifest = {
forms: {
count: totalForms,
entities: manifestEntities
}
}
archive.append(JSON.stringify(manifest, null, 2), {
name: 'manifest.json'
})
}

/**
* Retrieve both live and draft form definitions
* @param {string} id - The form metadata ID
Expand Down
44 changes: 43 additions & 1 deletion designer/server/src/routes/admin/index.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Boom from '@hapi/boom'
import archiver from 'archiver'
import { StatusCodes } from 'http-status-codes'

import { testFormDefinitionWithSinglePage } from '~/src/__stubs__/form-definition.js'
Expand Down Expand Up @@ -82,6 +83,20 @@ jest.mock('archiver', () => {
default: factory
}
})

function getManifestJsonFromArchive() {
const archiverMock = jest.mocked(archiver)
const instance = archiverMock.mock.results[0]?.value
const appendCalls = instance?.append?.mock.calls ?? []
const manifestCall = appendCalls.find(
(/** @type {{name:string}[]} */ call) => call[1]?.name === 'manifest.json'
)
if (!manifestCall) {
return undefined
}
return JSON.parse(manifestCall[0])
}

describe('System admin routes', () => {
/** @type {Server} */
let server
Expand Down Expand Up @@ -319,6 +334,25 @@ describe('System admin routes', () => {
expect.any(Number)
)

const manifest = getManifestJsonFromArchive()
expect(manifest).toMatchObject({
forms: {
count: 2,
entities: expect.arrayContaining([
expect.objectContaining({
id: testFormMetadata.id,
title: testFormMetadata.title,
slug: testFormMetadata.slug
}),
expect.objectContaining({
id: 'form-2',
title: testFormMetadata.title,
slug: 'form-2'
})
])
}
})

// Verify form definitions were requested
expect(forms.getLiveFormDefinition).toHaveBeenCalledTimes(2)
expect(forms.getDraftFormDefinition).toHaveBeenCalledTimes(2)
Expand Down Expand Up @@ -448,6 +482,14 @@ describe('System admin routes', () => {
expect.any(Number)
)

const manifest = getManifestJsonFromArchive()
expect(manifest).toMatchObject({
forms: {
count: 12
}
})
expect(manifest.forms.entities).toHaveLength(12)

// Verify all form definitions were fetched
expect(forms.getLiveFormDefinition).toHaveBeenCalledTimes(12)
expect(forms.getDraftFormDefinition).toHaveBeenCalledTimes(12)
Expand All @@ -458,5 +500,5 @@ describe('System admin routes', () => {
/**
* @import { Server } from '@hapi/hapi'
* @import Archiver from 'archiver'
* @import {Writable, PassThrough} from 'node:stream'
* @import {PassThrough} from 'node:stream'
*/
Loading