Skip to content

Commit fd2f975

Browse files
committed
Tests and documentation on uploadURI regex support
1 parent 4b8c448 commit fd2f975

File tree

6 files changed

+212
-15
lines changed

6 files changed

+212
-15
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ Besides the [core protocol](https://tus.io/protocols/resumable-upload.html#core-
3838
### 1. Setup
3939
The first step is to create a `TusFileUploadService` object using its constructor. You can make this object available as a (Spring bean) singleton or create a new instance for each request. After creating the object, you can configure it using the following methods:
4040

41-
* `withUploadURI(String)`: Set the relative URL under which the tus upload endpoint will be made available, for example `/files/upload`.
41+
* `withUploadURI(String)`: Set the relative URL under which the main tus upload endpoint will be made available, for example `/files/upload`. Optionally, this URI may contain regex parameters in order to support endpoints that contain URL parameters, for example `/users/[0-9]+/files/upload`.
4242
* `withMaxUploadSize(Long)`: Specify the maximum number of bytes that can be uploaded per upload. If you don't call this method, the maximum number of bytes is `Long.MAX_VALUE`.
4343
* `withStoragePath(String)`: If you're using the default filesystem-based storage service, you can use this method to specify the path where to store the uploaded bytes and upload information.
4444
* `withChunkedTransferDecoding`: You can enable or disable the decoding of chunked HTTP requests by this library. Enable this feature in case the web container in which this service is running does not decode chunked transfers itself. By default, chunked decoding via this library is disabled (as modern frameworks tend to already do this for you).

src/main/java/me/desair/tus/server/TusFileUploadService.java

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,18 +67,39 @@ protected void initFeatures() {
6767
addTusExtension(new ConcatenationExtension());
6868
}
6969

70+
/**
71+
* Set the URI under which the main tus upload endpoint is hosted.
72+
* Optionally, this URI may contain regex parameters in order to support endpoints that contain
73+
* URL parameters, for example /users/[0-9]+/files/upload
74+
*
75+
* @param uploadURI The URI of the main tus upload endpoint
76+
* @return The current service
77+
*/
7078
public TusFileUploadService withUploadURI(String uploadURI) {
71-
Validate.notBlank(uploadURI, "The upload URI pattern cannot be blank");
7279
this.idFactory.setUploadURI(uploadURI);
7380
return this;
7481
}
7582

83+
/**
84+
* Specify the maximum number of bytes that can be uploaded per upload.
85+
* If you don't call this method, the maximum number of bytes is Long.MAX_VALUE.
86+
*
87+
* @param maxUploadSize The maximum upload length that is allowed
88+
* @return The current service
89+
*/
7690
public TusFileUploadService withMaxUploadSize(Long maxUploadSize) {
7791
Validate.exclusiveBetween(0, Long.MAX_VALUE, maxUploadSize, "The max upload size must be bigger than 0");
7892
this.uploadStorageService.setMaxUploadSize(maxUploadSize);
7993
return this;
8094
}
8195

96+
/**
97+
* Provide a custom {@link UploadStorageService} implementation that should be used to store uploaded bytes and
98+
* metadata ({@link UploadInfo}).
99+
*
100+
* @param uploadStorageService The custom {@link UploadStorageService} implementation
101+
* @return The current service
102+
*/
82103
public TusFileUploadService withUploadStorageService(UploadStorageService uploadStorageService) {
83104
Validate.notNull(uploadStorageService, "The UploadStorageService cannot be null");
84105
//Copy over any previous configuration
@@ -91,6 +112,14 @@ public TusFileUploadService withUploadStorageService(UploadStorageService upload
91112
return this;
92113
}
93114

115+
/**
116+
* Provide a custom {@link UploadLockingService} implementation that should be used when processing uploads.
117+
* The upload locking service is responsible for locking an upload that is being processed so that it cannot
118+
* be corrupted by simultaneous or delayed requests.
119+
*
120+
* @param uploadLockingService The {@link UploadLockingService} implementation to use
121+
* @return The current service
122+
*/
94123
public TusFileUploadService withUploadLockingService(UploadLockingService uploadLockingService) {
95124
Validate.notNull(uploadLockingService, "The UploadStorageService cannot be null");
96125
uploadLockingService.setIdFactory(this.idFactory);
@@ -100,6 +129,13 @@ public TusFileUploadService withUploadLockingService(UploadLockingService upload
100129
return this;
101130
}
102131

132+
/**
133+
* If you're using the default filesystem-based storage service, you can use this method to
134+
* specify the path where to store the uploaded bytes and upload information.
135+
*
136+
* @param storagePath The file system path where uploads can be stored (temporarily)
137+
* @return The current service
138+
*/
103139
public TusFileUploadService withStoragePath(String storagePath) {
104140
Validate.notBlank(storagePath, "The storage path cannot be blank");
105141
withUploadStorageService(new DiskStorageService(idFactory, storagePath));
@@ -133,48 +169,102 @@ public TusFileUploadService withChunkedTransferDecoding(boolean isEnabled) {
133169
return this;
134170
}
135171

172+
/**
173+
* You can set the number of milliseconds after which an upload is considered as expired and available for cleanup.
174+
*
175+
* @param expirationPeriod The number of milliseconds after which an upload expires and can be removed
176+
* @return The current service
177+
*/
136178
public TusFileUploadService withUploadExpirationPeriod(Long expirationPeriod) {
137179
uploadStorageService.setUploadExpirationPeriod(expirationPeriod);
138180
return this;
139181
}
140182

183+
/**
184+
* Enable the unofficial `download` extension that also allows you to download uploaded bytes.
185+
* By default this feature is disabled.
186+
*
187+
* @return The current service
188+
*/
141189
public TusFileUploadService withDownloadFeature() {
142190
addTusExtension(new DownloadExtension());
143191
return this;
144192
}
145193

194+
/**
195+
* Add a custom (application-specific) extension that implements the `me.desair.tus.server.TusExtension` interface.
196+
* For example you can add your own extension that checks authentication and authorization policies within your
197+
* application for the user doing the upload.
198+
*
199+
* @param feature The custom extension implementation
200+
* @return The current service
201+
*/
146202
public TusFileUploadService addTusExtension(TusExtension feature) {
147203
Validate.notNull(feature, "A custom feature cannot be null");
148204
enabledFeatures.put(feature.getName(), feature);
149205
updateSupportedHttpMethods();
150206
return this;
151207
}
152208

153-
public TusFileUploadService disableTusExtension(String featureName) {
154-
Validate.notNull(featureName, "The feature name cannot be null");
155-
156-
if (StringUtils.equals("core", featureName)) {
157-
throw new IllegalArgumentException("The core protocol cannot be disabled");
158-
}
209+
/**
210+
* Disable the TusExtension for which the getName() method matches the provided string. The default extensions
211+
* have names "creation", "checksum", "expiration", "concatenation", "termination" and "download".
212+
* You cannot disable the "core" feature.
213+
*
214+
* @param extensionName The name of the extension to disable
215+
* @return The current service
216+
*/
217+
public TusFileUploadService disableTusExtension(String extensionName) {
218+
Validate.notNull(extensionName, "The extension name cannot be null");
219+
Validate.isTrue(!StringUtils.equals("core", extensionName), "The core protocol cannot be disabled");
159220

160-
enabledFeatures.remove(featureName);
221+
enabledFeatures.remove(extensionName);
161222
updateSupportedHttpMethods();
162223
return this;
163224
}
164225

226+
/**
227+
* Get all HTTP methods that are supported by this TusUploadService based on the enabled and/or disabled
228+
* tus extensions
229+
*
230+
* @return The set of enabled HTTP methods
231+
*/
165232
public Set<HttpMethod> getSupportedHttpMethods() {
166233
return EnumSet.copyOf(supportedHttpMethods);
167234
}
168235

236+
/**
237+
* Get the set of enabled Tus extensions
238+
* @return The set of active extensions
239+
*/
169240
public Set<String> getEnabledFeatures() {
170241
return new LinkedHashSet<>(enabledFeatures.keySet());
171242
}
172243

244+
/**
245+
* Process a tus upload request.
246+
* Use this method to process any request made to the main and sub tus upload endpoints. This corresponds to
247+
* the path specified in the withUploadURI() method and any sub-path of that URI.
248+
*
249+
* @param servletRequest The {@link HttpServletRequest} of the request
250+
* @param servletResponse The {@link HttpServletResponse} of the request
251+
* @throws IOException When saving bytes or information of this requests fails
252+
*/
173253
public void process(HttpServletRequest servletRequest, HttpServletResponse servletResponse)
174254
throws IOException {
175255
process(servletRequest, servletResponse, null);
176256
}
177257

258+
/**
259+
* Process a tus upload request that belongs to a specific owner.
260+
* Use this method to process any request made to the main and sub tus upload endpoints. This corresponds to
261+
* the path specified in the withUploadURI() method and any sub-path of that URI.
262+
*
263+
* @param servletRequest The {@link HttpServletRequest} of the request
264+
* @param servletResponse The {@link HttpServletResponse} of the request
265+
* @param ownerKey A unique identifier of the owner (group) of this upload
266+
* @throws IOException When saving bytes or information of this requests fails
267+
*/
178268
public void process(HttpServletRequest servletRequest, HttpServletResponse servletResponse,
179269
String ownerKey) throws IOException {
180270
Validate.notNull(servletRequest, "The HTTP Servlet request cannot be null");

src/main/java/me/desair/tus/server/upload/UploadIdFactory.java

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,39 @@
77
import org.apache.commons.lang3.StringUtils;
88
import org.apache.commons.lang3.Validate;
99

10+
/**
11+
* Factory to create unique upload IDs. This factory can also parse the upload identifier
12+
* from a given upload URL.
13+
*/
1014
public class UploadIdFactory {
1115

1216
private String uploadURI = "/";
1317
private Pattern uploadUriPattern = null;
1418

19+
/**
20+
* Set the URI under which the main tus upload endpoint is hosted.
21+
* Optionally, this URI may contain regex parameters in order to support endpoints that contain
22+
* URL parameters, for example /users/[0-9]+/files/upload
23+
*
24+
* @param uploadURI The URI of the main tus upload endpoint
25+
*/
1526
public void setUploadURI(String uploadURI) {
16-
Validate.notNull(uploadURI, "The upload URI pattern cannot be null");
27+
Validate.notBlank(uploadURI, "The upload URI pattern cannot be blank");
28+
Validate.isTrue(StringUtils.startsWith(uploadURI, "/"), "The upload URI should start with /");
29+
Validate.isTrue(!StringUtils.endsWith(uploadURI, "$"), "The upload URI should not end with $");
1730
this.uploadURI = uploadURI;
1831
this.uploadUriPattern = null;
1932
}
2033

34+
/**
35+
* Read the upload identifier from the given URL.
36+
* <p/>
37+
* Clients will send requests to upload URLs or provided URLs of completed uploads. This method is able to
38+
* parse those URLs and provide the user with the corresponding upload ID.
39+
*
40+
* @param url The URL provided by the client
41+
* @return The corresponding Upload identifier
42+
*/
2143
public UUID readUploadId(String url) {
2244
Matcher uploadUriMatcher = getUploadUriPattern().matcher(StringUtils.trimToEmpty(url));
2345
String pathId = uploadUriMatcher.replaceFirst("");
@@ -34,10 +56,18 @@ public UUID readUploadId(String url) {
3456
return id;
3557
}
3658

59+
/**
60+
* Return the URI of the main tus upload endpoint. Note that this value possibly contains regex parameters.
61+
* @return The URI of the main tus upload endpoint.
62+
*/
3763
public String getUploadURI() {
3864
return uploadURI;
3965
}
4066

67+
/**
68+
* Create a new unique upload ID
69+
* @return A new unique upload ID
70+
*/
4171
public synchronized UUID createId() {
4272
return UUID.randomUUID();
4373
}

src/test/java/me/desair/tus/server/ITTusFileUploadService.java

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1157,20 +1157,20 @@ public void testConcatenationUnfinished() throws Exception {
11571157
}
11581158

11591159
@Test
1160-
public void testChunkedDecodingDisabled() throws Exception {
1160+
public void testChunkedDecodingDisabledAndRegexUploadURI() throws Exception {
11611161
String chunkedContent = "1B;test=value\r\nThis upload looks chunked, \r\n"
11621162
+ "D\r\nbut it's not!\r\n"
11631163
+ "\r\n0\r\n";
11641164

11651165
//Create service without chunked decoding
11661166
tusFileUploadService = new TusFileUploadService()
1167-
.withUploadURI(UPLOAD_URI)
1167+
.withUploadURI("/users/[0-9]+/files/upload")
11681168
.withStoragePath(storagePath.toAbsolutePath().toString())
11691169
.withDownloadFeature();
11701170

11711171
//Create upload
11721172
servletRequest.setMethod("POST");
1173-
servletRequest.setRequestURI(UPLOAD_URI);
1173+
servletRequest.setRequestURI("/users/98765/files/upload");
11741174
servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
11751175
servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "67");
11761176
servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
@@ -1183,8 +1183,7 @@ public void testChunkedDecodingDisabled() throws Exception {
11831183
assertResponseHeaderNull(HttpHeader.UPLOAD_EXPIRES);
11841184
assertResponseStatus(HttpServletResponse.SC_CREATED);
11851185

1186-
String location = UPLOAD_URI +
1187-
StringUtils.substringAfter(servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
1186+
String location = servletResponse.getHeader(HttpHeader.LOCATION);
11881187

11891188
//Upload content
11901189
reset();

src/test/java/me/desair/tus/server/creation/ITCreationExtension.java

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import static org.hamcrest.MatcherAssert.assertThat;
66
import static org.mockito.Matchers.notNull;
77
import static org.mockito.Mockito.anyString;
8+
import static org.mockito.Mockito.reset;
89
import static org.mockito.Mockito.times;
910
import static org.mockito.Mockito.verify;
1011
import static org.mockito.Mockito.when;
@@ -47,6 +48,7 @@ public void setUp() throws Exception {
4748

4849
id = UUID.randomUUID();
4950
servletRequest.setRequestURI(UPLOAD_URI);
51+
reset(uploadStorageService);
5052
when(uploadStorageService.getUploadURI()).thenReturn(UPLOAD_URI);
5153
when(uploadStorageService.create(Matchers.any(UploadInfo.class), anyString())).then(new Answer<UploadInfo>() {
5254
@Override
@@ -200,4 +202,65 @@ public void testPostOnInvalidUrl() throws Exception {
200202

201203
executeCall(HttpMethod.POST, false);
202204
}
205+
206+
@Test
207+
public void testPostWithValidRegexURI() throws Exception {
208+
reset(uploadStorageService);
209+
when(uploadStorageService.getUploadURI()).thenReturn("/submission/([a-z0-9]+)/files/upload");
210+
when(uploadStorageService.create(Matchers.any(UploadInfo.class), anyString())).then(new Answer<UploadInfo>() {
211+
@Override
212+
public UploadInfo answer(InvocationOnMock invocation) throws Throwable {
213+
UploadInfo upload = invocation.getArgumentAt(0, UploadInfo.class);
214+
upload.setId(id);
215+
216+
when(uploadStorageService.getUploadInfo("/submission/0ae5f8vv4s8c/files/upload/" + id.toString(),
217+
invocation.getArgumentAt(1, String.class))).thenReturn(upload);
218+
return upload;
219+
}
220+
});
221+
222+
223+
//Create upload
224+
servletRequest.setRequestURI("/submission/0ae5f8vv4s8c/files/upload");
225+
servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, 9);
226+
servletRequest.addHeader(HttpHeader.UPLOAD_METADATA, "submission metadata");
227+
228+
executeCall(HttpMethod.POST, false);
229+
230+
verify(uploadStorageService, times(1)).create(notNull(UploadInfo.class), anyString());
231+
assertResponseHeader(HttpHeader.LOCATION, "/submission/0ae5f8vv4s8c/files/upload/" + id.toString());
232+
assertResponseStatus(HttpServletResponse.SC_CREATED);
233+
234+
//Check data with head request
235+
servletRequest.setRequestURI("/submission/0ae5f8vv4s8c/files/upload/" + id.toString());
236+
servletResponse = new MockHttpServletResponse();
237+
executeCall(HttpMethod.HEAD, false);
238+
239+
assertThat(servletResponse.getHeader(HttpHeader.UPLOAD_METADATA), is("submission metadata"));
240+
assertThat(servletResponse.getHeader(HttpHeader.UPLOAD_DEFER_LENGTH), is(nullValue()));
241+
}
242+
243+
@Test(expected = PostOnInvalidRequestURIException.class)
244+
public void testPostWithInvalidRegexURI() throws Exception {
245+
reset(uploadStorageService);
246+
when(uploadStorageService.getUploadURI()).thenReturn("/submission/([a-z0-9]+)/files/upload");
247+
when(uploadStorageService.create(Matchers.any(UploadInfo.class), anyString())).then(new Answer<UploadInfo>() {
248+
@Override
249+
public UploadInfo answer(InvocationOnMock invocation) throws Throwable {
250+
UploadInfo upload = invocation.getArgumentAt(0, UploadInfo.class);
251+
upload.setId(id);
252+
253+
when(uploadStorageService.getUploadInfo("/submission/0ae5f8vv4s8c/files/upload/" + id.toString(),
254+
invocation.getArgumentAt(1, String.class))).thenReturn(upload);
255+
return upload;
256+
}
257+
});
258+
259+
//Create upload
260+
servletRequest.setRequestURI("/submission/a+b/files/upload");
261+
servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, 9);
262+
servletRequest.addHeader(HttpHeader.UPLOAD_METADATA, "submission metadata");
263+
264+
executeCall(HttpMethod.POST, false);
265+
}
203266
}

src/test/java/me/desair/tus/server/upload/UploadIdFactoryTest.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,21 @@ public void setUploadURIWithTrailingSlash() throws Exception {
3535
assertThat(idFactory.getUploadURI(), is("/test/upload/"));
3636
}
3737

38+
@Test(expected = IllegalArgumentException.class)
39+
public void setUploadURIBlank() throws Exception {
40+
idFactory.setUploadURI(" ");
41+
}
42+
43+
@Test(expected = IllegalArgumentException.class)
44+
public void setUploadURINoStartingSlash() throws Exception {
45+
idFactory.setUploadURI("test/upload/");
46+
}
47+
48+
@Test(expected = IllegalArgumentException.class)
49+
public void setUploadURIEndsWithDollar() throws Exception {
50+
idFactory.setUploadURI("/test/upload$");
51+
}
52+
3853
@Test
3954
public void readUploadId() throws Exception {
4055
idFactory.setUploadURI("/test/upload");

0 commit comments

Comments
 (0)