Skip to content

Commit

Permalink
@tus/server: allow onUploadFinish hook to override response data (#615)
Browse files Browse the repository at this point in the history
Co-authored-by: Merlijn Vos <merlijn@soverin.net>
  • Loading branch information
netdown and Murderlon committed May 13, 2024
1 parent 60698da commit 0f90980
Show file tree
Hide file tree
Showing 7 changed files with 150 additions and 24 deletions.
5 changes: 5 additions & 0 deletions .changeset/small-pandas-laugh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tus/server': minor
---

Allow onUploadFinish hook to override response data
10 changes: 6 additions & 4 deletions packages/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,11 +157,13 @@ This can be used to implement validation of upload metadata or add headers.
#### `options.onUploadFinish`

`onUploadFinish` will be invoked after an upload is completed but before a response is
returned to the client (`(req, res, upload) => Promise<res>`).
returned to the client (`(req, res, upload) => Promise<{ res: http.ServerResponse, status_code?: number, headers?: Record<string, string | number>, body?: string }>`).

If the function returns the (modified) response, the upload will finish. You can `throw`
an Object and the HTTP request will be aborted with the provided `body` and `status_code`
(or their fallbacks).
- You can optionally return `status_code`, `headers` and `body` to modify the response.
Note that the tus specification does not allow sending response body nor status code
other than 204, but most clients support it. Use at your own risk.
- You can `throw` an Object and the HTTP request will be aborted with the provided `body`
and `status_code` (or their fallbacks).

This can be used to implement post-processing validation.

Expand Down
45 changes: 35 additions & 10 deletions packages/server/src/handlers/PatchHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,22 +107,42 @@ export class PatchHandler extends BaseHandler {

upload.offset = newOffset
this.emit(EVENTS.POST_RECEIVE, req, res, upload)

//Recommended response defaults
const responseData = {
status: 204,
headers: {
'Upload-Offset': newOffset,
} as Record<string, string | number>,
body: '',
}

if (newOffset === upload.size && this.options.onUploadFinish) {
try {
res = await this.options.onUploadFinish(req, res, upload)
const resOrObject = await this.options.onUploadFinish(req, res, upload)
// Backwards compatibility, remove in next major
// Ugly check because we can't use `instanceof` because we mock the instance in tests
if (
typeof (resOrObject as http.ServerResponse).write === 'function' &&
typeof (resOrObject as http.ServerResponse).writeHead === 'function'
) {
res = resOrObject as http.ServerResponse
} else {
// Ugly types because TS only understands instanceof
type ExcludeServerResponse<T> = T extends http.ServerResponse ? never : T
const obj = resOrObject as ExcludeServerResponse<typeof resOrObject>
res = obj.res
if (obj.status_code) responseData.status = obj.status_code
if (obj.body) responseData.body = obj.body
if (obj.headers)
responseData.headers = Object.assign(obj.headers, responseData.headers)
}
} catch (error) {
log(`onUploadFinish: ${error.body}`)
throw error
}
}

const headers: {
'Upload-Offset': number
'Upload-Expires'?: string
} = {
'Upload-Offset': newOffset,
}

if (
this.store.hasExtension('expiration') &&
this.store.getExpiration() > 0 &&
Expand All @@ -134,11 +154,16 @@ export class PatchHandler extends BaseHandler {
const dateString = new Date(
creation.getTime() + this.store.getExpiration()
).toUTCString()
headers['Upload-Expires'] = dateString
responseData.headers['Upload-Expires'] = dateString
}

// The Server MUST acknowledge successful PATCH requests with the 204
const writtenRes = this.write(res, 204, headers)
const writtenRes = this.write(
res,
responseData.status,
responseData.headers,
responseData.body
)

if (newOffset === upload.size) {
this.emit(EVENTS.POST_FINISH, req, writtenRes, upload)
Expand Down
48 changes: 40 additions & 8 deletions packages/server/src/handlers/PostHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,12 @@ export class PostHandler extends BaseHandler {

let isFinal: boolean
let url: string
let headers: {
'Upload-Offset'?: string
'Upload-Expires'?: string

//Recommended response defaults
const responseData = {
status: 201,
headers: {} as Record<string, string | number>,
body: '',
}

try {
Expand All @@ -139,14 +142,13 @@ export class PostHandler extends BaseHandler {
this.emit(EVENTS.POST_CREATE, req, res, upload, url)

isFinal = upload.size === 0 && !upload.sizeIsDeferred
headers = {}

// The request MIGHT include a Content-Type header when using creation-with-upload extension
if (validateHeader('content-type', req.headers['content-type'])) {
const bodyMaxSize = await this.calculateMaxBodySize(req, upload, maxFileSize)
const newOffset = await this.writeToStore(req, upload, bodyMaxSize, context)

headers['Upload-Offset'] = newOffset.toString()
responseData.headers['Upload-Offset'] = newOffset.toString()
isFinal = newOffset === Number.parseInt(upload_length as string, 10)
upload.offset = newOffset
}
Expand All @@ -159,7 +161,24 @@ export class PostHandler extends BaseHandler {

if (isFinal && this.options.onUploadFinish) {
try {
res = await this.options.onUploadFinish(req, res, upload)
const resOrObject = await this.options.onUploadFinish(req, res, upload)
// Backwards compatibility, remove in next major
// Ugly check because we can't use `instanceof` because we mock the instance in tests
if (
typeof (resOrObject as http.ServerResponse).write === 'function' &&
typeof (resOrObject as http.ServerResponse).writeHead === 'function'
) {
res = resOrObject as http.ServerResponse
} else {
// Ugly types because TS only understands instanceof
type ExcludeServerResponse<T> = T extends http.ServerResponse ? never : T
const obj = resOrObject as ExcludeServerResponse<typeof resOrObject>
res = obj.res
if (obj.status_code) responseData.status = obj.status_code
if (obj.body) responseData.body = obj.body
if (obj.headers)
responseData.headers = Object.assign(obj.headers, responseData.headers)
}
} catch (error) {
log(`onUploadFinish: ${error.body}`)
throw error
Expand All @@ -178,13 +197,26 @@ export class PostHandler extends BaseHandler {
if (created.offset !== Number.parseInt(upload_length as string, 10)) {
const creation = new Date(upload.creation_date)
// Value MUST be in RFC 7231 datetime format
headers['Upload-Expires'] = new Date(
responseData.headers['Upload-Expires'] = new Date(
creation.getTime() + this.store.getExpiration()
).toUTCString()
}
}

const writtenRes = this.write(res, 201, {Location: url, ...headers})
//Only append Location header if its valid for the final http status (201 or 3xx)
if (
responseData.status === 201 ||
(responseData.status >= 300 && responseData.status < 400)
) {
responseData.headers['Location'] = url
}

const writtenRes = this.write(
res,
responseData.status,
responseData.headers,
responseData.body
)

if (isFinal) {
this.emit(EVENTS.POST_FINISH, req, writtenRes, upload)
Expand Down
14 changes: 12 additions & 2 deletions packages/server/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ export type ServerOptions = {

/**
* `onUploadFinish` will be invoked after an upload is completed but before a response is returned to the client.
* If the function returns the (modified) response, the upload will finish.
* You can optionally return `status_code`, `headers` and `body` to modify the response.
* Note that the tus specification does not allow sending response body nor status code other than 204, but most clients support it.
* If an error is thrown, the HTTP request will be aborted, and the provided `body` and `status_code`
* (or their fallbacks) will be sent to the client. This can be used to implement post-processing validation.
* @param req - The incoming HTTP request.
Expand All @@ -118,7 +119,16 @@ export type ServerOptions = {
req: http.IncomingMessage,
res: http.ServerResponse,
upload: Upload
) => Promise<http.ServerResponse>
) => Promise<
// TODO: change in the next major
| http.ServerResponse
| {
res: http.ServerResponse
status_code?: number
headers?: Record<string, string | number>
body?: string
}
>

/**
* `onIncomingRequest` will be invoked when an incoming request is received.
Expand Down
18 changes: 18 additions & 0 deletions packages/server/test/PostHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,24 @@ describe('PostHandler', () => {
assert.equal(upload.offset, 0)
assert.equal(upload.size, 0)
})

it('does not set Location header if onUploadFinish hook returned a not eligible status code', async function () {
const store = sinon.createStubInstance(DataStore)
const handler = new PostHandler(store, {
path: '/test/output',
locker: new MemoryLocker(),
onUploadFinish: async (req, res) => ({res, status_code: 200}),
})

req.headers = {
'upload-length': '0',
host: 'localhost:3000',
}
store.create.resolvesArg(0)

await handler.send(req, res, context)
assert.equal('location' in res._getHeaders(), false)
})
})
})
})
34 changes: 34 additions & 0 deletions packages/server/test/Server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,40 @@ describe('Server', () => {
.expect(500, 'no', done)
})

it('should allow response to be changed in onUploadFinish', (done) => {
const server = new Server({
path: '/test/output',
datastore: new FileStore({directory}),
async onUploadFinish(_, res) {
return {
res,
status_code: 200,
body: '{ fileProcessResult: 12 }',
headers: {'X-TestHeader': '1'},
}
},
})

request(server.listen())
.post(server.options.path)
.set('Tus-Resumable', TUS_RESUMABLE)
.set('Upload-Length', '4')
.then((res) => {
request(server.listen())
.patch(removeProtocol(res.headers.location))
.send('test')
.set('Tus-Resumable', TUS_RESUMABLE)
.set('Upload-Offset', '0')
.set('Content-Type', 'application/offset+octet-stream')
.expect(200, '{ fileProcessResult: 12 }')
.then((r) => {
assert.equal(r.headers['upload-offset'], '4')
assert.equal(r.headers['x-testheader'], '1')
done()
})
})
})

it('should fire when an upload is finished with upload-defer-length', (done) => {
const length = Buffer.byteLength('test', 'utf8').toString()
server.on(EVENTS.POST_FINISH, (req, res, upload) => {
Expand Down

0 comments on commit 0f90980

Please sign in to comment.