Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

@tus/server: allow onUploadFinish hook to override response data #615

Merged
merged 8 commits into from
May 13, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
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
11 changes: 7 additions & 4 deletions packages/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,11 +157,14 @@ 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).
- If the function returns the (modified) response, the upload will finish.
netdown marked this conversation as resolved.
Show resolved Hide resolved
- 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
40 changes: 32 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,18 @@ 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})
const writtenRes = this.write(
res,
responseData.status,
Object.assign({Location: url}, responseData.headers),
Murderlon marked this conversation as resolved.
Show resolved Hide resolved
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.
* If the function returns the (modified) response, the upload will finish. You can optionally return `status_code`, `headers` and `body` to modify the response.
netdown marked this conversation as resolved.
Show resolved Hide resolved
* 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
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
Loading