Skip to content

Commit 08e53c4

Browse files
Csv upload (#77)
* fix: remove console.log * feat: export large compressed csv * fix: expected lines * fix: id is not 1 as db is not reset
1 parent edc23ce commit 08e53c4

File tree

3 files changed

+88
-3
lines changed

3 files changed

+88
-3
lines changed

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"@fastify/under-pressure": "^9.0.1",
5252
"@sinclair/typebox": "^0.34.11",
5353
"concurrently": "^9.0.1",
54+
"csv-stringify": "^6.5.2",
5455
"fastify": "^5.0.0",
5556
"fastify-cli": "^7.0.0",
5657
"fastify-plugin": "^5.0.1",

src/routes/api/tasks/index.ts

+32-1
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ import {
1212
TaskPaginationResultSchema
1313
} from '../../../schemas/tasks.js'
1414
import path from 'node:path'
15-
import { pipeline } from 'node:stream/promises'
1615
import fs from 'node:fs'
16+
import { pipeline } from 'node:stream/promises'
17+
import { createGzip } from 'node:zlib'
18+
import { stringify } from 'csv-stringify'
1719

1820
const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
1921
fastify.get(
@@ -355,6 +357,35 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
355357
})
356358
}
357359
)
360+
361+
fastify.get(
362+
'/download/csv',
363+
{
364+
schema: {
365+
response: {
366+
200: { type: 'string', contentMediaType: 'application/gzip' },
367+
400: Type.Object({ message: Type.String() })
368+
},
369+
tags: ['Tasks']
370+
}
371+
},
372+
async function (request, reply) {
373+
const queryStream = fastify.knex.select('*').from('tasks').stream()
374+
375+
const csvTransform = stringify({
376+
header: true,
377+
columns: undefined
378+
})
379+
380+
reply.header('Content-Type', 'application/gzip')
381+
reply.header(
382+
'Content-Disposition',
383+
`attachment; filename="${encodeURIComponent('tasks.csv.gz')}"`
384+
)
385+
386+
return queryStream.pipe(csvTransform).pipe(createGzip())
387+
}
388+
)
358389
}
359390

360391
function isErrnoException (error: unknown): error is NodeJS.ErrnoException {

test/routes/api/tasks/tasks.test.ts

+55-2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { pipeline } from 'node:stream/promises'
1313
import path from 'node:path'
1414
import FormData from 'form-data'
1515
import os from 'os'
16+
import { gunzipSync } from 'node:zlib'
1617

1718
async function createUser (
1819
app: FastifyInstance,
@@ -28,8 +29,16 @@ async function createTask (app: FastifyInstance, taskData: Partial<Task>) {
2829
return id
2930
}
3031

31-
async function uploadImageForTask (app: FastifyInstance, taskId: number, filePath: string, uploadDir: string) {
32-
await app.knex<Task>('tasks').where({ id: taskId }).update({ filename: `${taskId}_short-logo.png` })
32+
async function uploadImageForTask (
33+
app: FastifyInstance,
34+
taskId: number,
35+
filePath: string,
36+
uploadDir: string
37+
) {
38+
await app
39+
.knex<Task>('tasks')
40+
.where({ id: taskId })
41+
.update({ filename: `${taskId}_short-logo.png` })
3342

3443
const file = fs.createReadStream(filePath)
3544
const filename = `${taskId}_short-logo.png`
@@ -803,4 +812,48 @@ describe('Tasks api (logged user only)', () => {
803812
})
804813
})
805814
})
815+
816+
describe('GET /api/tasks/download/csv', () => {
817+
before(async () => {
818+
const app = await build()
819+
await app.knex('tasks').del()
820+
await app.close()
821+
})
822+
823+
it('should stream a gzipped CSV file', async (t) => {
824+
const app = await build(t)
825+
826+
const tasks = []
827+
for (let i = 0; i < 1000; i++) {
828+
tasks.push({
829+
name: `Task ${i + 1}`,
830+
author_id: 1,
831+
assigned_user_id: 2,
832+
filename: 'task.png',
833+
status: TaskStatusEnum.InProgress
834+
})
835+
}
836+
837+
await app.knex('tasks').insert(tasks)
838+
839+
const res = await app.injectWithLogin('basic', {
840+
method: 'GET',
841+
url: '/api/tasks/download/csv'
842+
})
843+
844+
assert.strictEqual(res.statusCode, 200)
845+
assert.strictEqual(res.headers['content-type'], 'application/gzip')
846+
assert.strictEqual(
847+
res.headers['content-disposition'],
848+
'attachment; filename="tasks.csv.gz"'
849+
)
850+
851+
const decompressed = gunzipSync(res.rawPayload).toString('utf-8')
852+
const lines = decompressed.split('\n')
853+
assert.equal(lines.length - 1, 1001)
854+
855+
assert.ok(lines[1].includes('Task 1,1,2,task.png,in-progress'))
856+
assert.equal(lines[0], 'id,name,author_id,assigned_user_id,filename,status,created_at,updated_at')
857+
})
858+
})
806859
})

0 commit comments

Comments
 (0)