Skip to content

Commit

Permalink
@uppy/xhr-upload: introduce hooks similar to tus (#5094)
Browse files Browse the repository at this point in the history
  • Loading branch information
Murderlon committed May 9, 2024
1 parent 12e7ca5 commit 7a9eb87
Show file tree
Hide file tree
Showing 4 changed files with 113 additions and 218 deletions.
149 changes: 60 additions & 89 deletions docs/uploader/xhr.mdx
Expand Up @@ -148,6 +148,15 @@ The function syntax is not available when [`bundle`](#bundle) is set to `true`.

:::

:::note

Failed requests are retried with the same headers. If you want to change the
headers on retry,
[such as refreshing an auth token](#how-can-I-refresh-auth-tokens-after-they-expire),
you can use [`onBeforeRequest`](#onbeforerequest).

:::

#### `bundle`

Send all files in a single multipart request (`boolean`, default: `false`).
Expand Down Expand Up @@ -176,92 +185,6 @@ uppy.setFileState(fileID, {
});
```

#### `validateStatus`

Check if the response was successful (`function`, default:
`(status, responseText, response) => boolean`).

- By default, responses with a 2xx HTTP status code are considered successful.
- When `true`, [`getResponseData()`](#getResponseData-responseText-response)
will be called and the upload will be marked as successful.
- When `false`, both
[`getResponseData()`](#getResponseData-responseText-response) and
[`getResponseError()`](#getResponseError-responseText-response) will be called
and the upload will be marked as unsuccessful.

##### Parameters

- The `statusCode` is the numeric HTTP status code returned by the endpoint.
- The `responseText` is the XHR endpoint response as a string.
- `response` is the [XMLHttpRequest][] object.

:::note

This option is only used for **local** uploads. Uploads from remote providers
like Google Drive or Instagram do not support this and will always use the
default.

:::

#### `getResponseData`

Extract the response data from the successful upload (`function`, default:
`(responseText, response) => void`).

- `responseText` is the XHR endpoint response as a string.
- `response` is the [XMLHttpRequest][] object.

JSON is handled automatically, so you should only use this if the endpoint
responds with a different format. For example, an endpoint that responds with an
XML document:

```js
function getResponseData(responseText, response) {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(responseText, 'text/xml');
return {
url: xmlDoc.querySelector('Location').textContent,
};
}
```

:::note

This response data will be available on the file’s `.response` property and will
be emitted in the [`upload-success`][uppy.upload-success] event.

:::

:::note

When uploading files from remote providers such as Dropbox or Instagram,
Companion sends upload response data to the client. This is made available in
the `getResponseData()` function as well. The `response` object from Companion
has some properties named after their [XMLHttpRequest][] counterparts.

:::

#### `getResponseError`

Extract the error from the failed upload (`function`, default:
`(responseText, response) => void`).

For example, if the endpoint responds with a JSON object containing a
`{ message }` property, this would show that message to the user:

```js
function getResponseError(responseText, response) {
return new Error(JSON.parse(responseText).message);
}
```

#### `responseUrlFieldName`

The field name containing the location of the uploaded file (`string`, default:
`'url'`).

This is returned by [`getResponseData()`](#getResponseData).

#### `timeout: 30 * 1000`

Abort the connection if no upload progress events have been received for this
Expand Down Expand Up @@ -291,6 +214,26 @@ by browsers, so it’s recommended to use one of those.
Indicates whether cross-site Access-Control requests should be made using
credentials (`boolean`, default: `false`).

### `onBeforeRequest`

An optional function that will be called before a HTTP request is sent out
(`(xhr: XMLHttpRequest, retryCount: number) => void | Promise<void>`).

### `shouldRetry`

An optional function called once an error appears and before retrying
(`(xhr: XMLHttpRequesT) => boolean`).

The amount of retries is 3, even if you continue to return `true`. The default
behavior uses
[exponential backoff](https://en.wikipedia.org/wiki/Exponential_backoff) with a
maximum of 3 retries.

### `onAfterResponse`

An optional function that will be called after a HTTP response has been received
(`(xhr: XMLHttpRequest, retryCount: number) => void | Promise<void>`).

#### `locale: {}`

```js
Expand All @@ -304,6 +247,37 @@ export default {

## Frequently Asked Questions

### How can I refresh auth tokens after they expire?

```js
import Uppy from '@uppy/core';
import XHR from '@uppy/xhr-upload';

let token = null;

async function getAuthToken() {
const res = await fetch('/auth/token');
const json = await res.json();
return json.token;
}

new Uppy().use(XHR, {
endpoint: '<your-endpoint>',
// Called again for every retry too.
async onBeforeRequest(xhr) {
if (!token) {
token = await getAuthToken();
}
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
},
async onAfterResponse(xhr) {
if (xhr.status === 401) {
token = await getAuthToken();
}
},
});
```

### How to send along meta data with the upload?

When using XHRUpload with [`formData: true`](#formData-true), file metadata is
Expand Down Expand Up @@ -384,13 +358,10 @@ move_uploaded_file($file_path, $_SERVER['DOCUMENT_ROOT'] . '/img/' . basename($f
```

[formdata]: https://developer.mozilla.org/en-US/docs/Web/API/FormData
[xmlhttprequest]:
https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest
[xhr.timeout]:
https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/timeout
[xhr.responsetype]:
https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/responseType
[uppy.upload-success]: /docs/uppy/#upload-success
[uppy file]: /docs/uppy#working-with-uppy-files
[php.file-upload]: https://secure.php.net/manual/en/features.file-upload.php
[php.multiple]:
Expand Down
6 changes: 3 additions & 3 deletions packages/@uppy/utils/src/fetcher.ts
Expand Up @@ -38,7 +38,7 @@ export type FetcherOptions = {
shouldRetry?: (xhr: XMLHttpRequest) => boolean

/** Called after the response has succeeded or failed but before the promise is resolved. */
onAfterRequest?: (
onAfterResponse?: (
xhr: XMLHttpRequest,
retryCount: number,
) => void | Promise<void>
Expand Down Expand Up @@ -67,7 +67,7 @@ export function fetcher(
onBeforeRequest = noop,
onUploadProgress = noop,
shouldRetry = () => true,
onAfterRequest = noop,
onAfterResponse = noop,
onTimeout = noop,
responseType,
retries = 3,
Expand Down Expand Up @@ -99,7 +99,7 @@ export function fetcher(
})

xhr.onload = async () => {
await onAfterRequest(xhr, retryCount)
await onAfterResponse(xhr, retryCount)

if (xhr.status >= 200 && xhr.status < 300) {
timer.done()
Expand Down
112 changes: 34 additions & 78 deletions packages/@uppy/xhr-upload/src/index.test.ts
Expand Up @@ -4,88 +4,44 @@ import Core from '@uppy/core'
import XHRUpload from './index.ts'

describe('XHRUpload', () => {
describe('getResponseData', () => {
it('has the XHRUpload options as its `this`', () => {
nock('https://fake-endpoint.uppy.io')
.defaultReplyHeaders({
'access-control-allow-method': 'POST',
'access-control-allow-origin': '*',
})
.options('/')
.reply(200, {})
.post('/')
.reply(200, {})

const core = new Core()
const getResponseData = vi.fn(function getResponseData() {
// @ts-expect-error TS can't know the type
expect(this.some).toEqual('option')
return {}
})
core.use(XHRUpload, {
id: 'XHRUpload',
endpoint: 'https://fake-endpoint.uppy.io',
// @ts-expect-error that option does not exist
some: 'option',
getResponseData,
})
core.addFile({
type: 'image/png',
source: 'test',
name: 'test.jpg',
data: new Blob([new Uint8Array(8192)]),
})

return core.upload().then(() => {
expect(getResponseData).toHaveBeenCalled()
it('should leverage hooks from fetcher', () => {
nock('https://fake-endpoint.uppy.io')
.defaultReplyHeaders({
'access-control-allow-method': 'POST',
'access-control-allow-origin': '*',
})
})
})
.options('/')
.reply(204, {})
.post('/')
.reply(401, {})
.options('/')
.reply(204, {})
.post('/')
.reply(200, {})

describe('validateStatus', () => {
it('emit upload error under status code 200', () => {
nock('https://fake-endpoint.uppy.io')
.defaultReplyHeaders({
'access-control-allow-method': 'POST',
'access-control-allow-origin': '*',
})
.options('/')
.reply(200, {})
.post('/')
.reply(200, {
code: 40000,
message: 'custom upload error',
})
const core = new Core()
const shouldRetry = vi.fn(() => true)
const onBeforeRequest = vi.fn(() => {})
const onAfterResponse = vi.fn(() => {})

const core = new Core()
const validateStatus = vi.fn((status, responseText) => {
return JSON.parse(responseText).code !== 40000
})

core.use(XHRUpload, {
id: 'XHRUpload',
endpoint: 'https://fake-endpoint.uppy.io',
// @ts-expect-error that option doesn't exist
some: 'option',
validateStatus,
getResponseError(responseText) {
return JSON.parse(responseText).message
},
})
core.addFile({
type: 'image/png',
source: 'test',
name: 'test.jpg',
data: new Blob([new Uint8Array(8192)]),
})
core.use(XHRUpload, {
id: 'XHRUpload',
endpoint: 'https://fake-endpoint.uppy.io',
shouldRetry,
onBeforeRequest,
onAfterResponse,
})
core.addFile({
type: 'image/png',
source: 'test',
name: 'test.jpg',
data: new Blob([new Uint8Array(8192)]),
})

return core.upload().then((result) => {
expect(validateStatus).toHaveBeenCalled()
expect(result!.failed!.length).toBeGreaterThan(0)
result!.failed!.forEach((file) => {
expect(file.error).toEqual('custom upload error')
})
})
return core.upload().then(() => {
expect(shouldRetry).toHaveBeenCalledTimes(1)
expect(onAfterResponse).toHaveBeenCalledTimes(2)
expect(onBeforeRequest).toHaveBeenCalledTimes(2)
})
})

Expand Down

0 comments on commit 7a9eb87

Please sign in to comment.