TUS Uploads — integration guide#
Walks a developer through integrating the tusmgmt extension into a Spring Boot application. For the capability overview and the request lifecycle diagram, see the extension index.
You’ll be done in five files + one properties block.
1. Add the dependency#
Depending on your build layout:
Same multi-module Gradle build (if you’ve vendored tusmgmt as a local subproject):
dependencies {
implementation project(':tusmgmt')
}External artifact (once published — placeholder coords):
dependencies {
implementation 'com.palmyralabs.palmyra.extn:palmyra-ext-tusmgmt:<version>'
// Transitive: me.desair.tus:tus-java-server, spring-web, spring-boot,
// commons-lang3, slf4j-api, jakarta.servlet-api
}No other framework pieces to pull in. tusmgmt is not dependent on palmyra-spring, palmyra-dbacl-mgmt or any other palmyra jar — it slots into a non-Palmyra Spring Boot app just as well.
2. Provision a metadata table#
The extension does NOT ship DDL — you own the attachment schema. The minimum columns an IndexStorageService implementation needs:
CREATE TABLE my_attachment (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
ciid BIGINT NOT NULL, -- owning row id
citid INT NOT NULL, -- owning ci-type (0 if unused)
filename VARCHAR(256) NOT NULL,
fid VARCHAR(150) UNIQUE, -- TUS UploadId.toString()
file_offset BIGINT, -- bytes written so far
size BIGINT, -- total bytes expected
expiration BIGINT, -- epoch millis; NULL when complete
time DATETIME,
path VARCHAR(1024) NOT NULL UNIQUE, -- relative to upload-location
remote_path VARCHAR(1024), -- stores the ownerKey for restore
filetype VARCHAR(150),
tag VARCHAR(150),
status DECIMAL(5,2) NOT NULL, -- 0 → 100.00 (percent complete)
created_by VARCHAR(50) NOT NULL,
last_upd_by VARCHAR(50) NOT NULL,
created_on DATETIME NOT NULL,
last_upd_on DATETIME NOT NULL
);Column names are not prescribed — IndexStorageService is an abstract SPI. The columns above map cleanly to the UploadInfo fields; rename them freely as long as your implementation bridges correctly. Reuse a framework-provided shape like xpm_attachment if you want cross-app interop.
3. Implement IndexStorageService#
One bean. About 150 lines. Happy-path skeleton:
package com.example.filemgmt;
@Service
@RequiredArgsConstructor
public class MyAttachmentIndexStorage implements IndexStorageService {
private final MyAttachmentJpaRepo repo;
private final FileUploadHandlerRegistry registry;
private UploadIdFactory idFactory = new UuidUploadIdFactory();
@Override
public UploadInfo create(UploadInfo info, String ownerKey) {
FileOwnerInfo owner = new FileOwnerInfo(ownerKey);
FileUploadHandler handler = registry.resolve(owner.getReference());
if (info.getId() == null) info.setId(idFactory.createId());
String path = handler.generateFilePath(owner, info.getFileName(), info.getMetadata());
MyAttachmentEntity a = new MyAttachmentEntity();
a.setFid(info.getId().toString());
a.setCiid(owner.getCiId().intValue());
a.setCitid(owner.getCitId());
a.setFilename(info.getFileName());
a.setFileOffset(info.getOffset());
a.setSize(info.getLength());
a.setPath(path);
a.setRemotePath(ownerKey);
a.setStatus(percent(info));
// ... audit fields ...
repo.save(a);
return info;
}
@Override
public UploadInfo update(UploadInfo info) throws UploadNotFoundException {
MyAttachmentEntity a = repo.findByFid(info.getId().toString())
.orElseThrow(() -> new UploadNotFoundException("fid " + info.getId()));
a.setFileOffset(info.getOffset());
a.setStatus(percent(info));
// ...
repo.save(a);
if (info.getOffset().equals(info.getLength())) {
FileOwnerInfo owner = new FileOwnerInfo(info.getOwnerKey());
registry.resolve(owner.getReference())
.onComplete(owner, info.getFileName(), info.getMetadata(), a);
}
return info;
}
@Override public UploadInfo getUploadInfo(UploadId id) {
return repo.findByFid(id.toString()).map(this::toUploadInfo).orElse(null);
}
@Override public String getRelativePath(UploadInfo info) {
return repo.findByFid(info.getId().toString())
.map(MyAttachmentEntity::getPath).orElse(null);
}
// delete, isUploadIdExists, getUploadInfo(url, ownerKey),
// forEachExpiredUpload, forEachUpload, setIdFactory, getIdFactory
// ... remaining methods follow the same pattern.
}Key rules:
info.getId().toString()is the primary key-by-business-value (stored infid).- When
offset == length, callhandler.onCompletewith the just-saved row. - Keep
getRelativePathside-effect-free — it’s called on every PATCH to locate the on-disk bytes.
4. Implement per-subtype FileUploadHandler beans#
One bean per domain-owned upload target. Each declares its subtype via @TusUpload("…"):
package com.example.orders;
@Component
@TusUpload("Order")
@RequiredArgsConstructor
public class OrderUploadHandler implements FileUploadHandler {
private final OrderJpaRepo orderRepo;
@Override
public String generateFilePath(FileOwnerInfo owner, String filename,
Map<String, String> metadata) {
// Ex: orders/{orderId}/{filename}
return owner.getDefaultFilePath(filename);
}
@Override
public void onComplete(FileOwnerInfo owner, String filename,
Map<String, String> metadata, Object attachment) {
// e.g. publish a domain event, trigger downstream processing
// attachment is your entity; cast as needed.
}
}Upload URLs for this handler:
POST /api/tusupload/Order/{orderId}
PATCH /api/tusupload/Order/{orderId}/{fid}{Order} matches @TusUpload("Order"). The registry is case-sensitive. Multiple handlers coexist — add one per subtype.
5. application.yml — the minimum you need#
palmyra:
servlet:
prefix-path: /api # shared with palmyra CRUD URLs
tus:
upload-location: /var/lib/my-app/uploads
# everything else has sensible defaults; see TusMgmtPropertiesSee the extension overview for the full property reference.
6. Enable scheduling#
In your main class (or any @Configuration), add:
@SpringBootApplication
@EnableScheduling
public class MyApp { ... }Without @EnableScheduling, the TusExpirationSweeper bean is created but never fires — stale uploads accumulate indefinitely.
7. Wire into Spring Security#
The TUS controller sits at ${palmyra.servlet.prefix-path}/tusupload/**. Make sure your filter chain permits (or requires auth for) that path as your policy dictates:
.authorizeHttpRequests(a -> a
.requestMatchers("/tusupload/**").authenticated()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())TUS requests use standard HTTP methods (POST, PATCH, HEAD, OPTIONS, DELETE), so normal Basic / bearer / cookie auth works.
8. Client-side#
Use a TUS-spec client library (tus-js-client, tus-java-client, etc.), or hand-roll against the wire protocol. Minimal curl flow:
# 1) create
curl -i -u user:pass \
-H "Tus-Resumable: 1.0.0" \
-H "Upload-Length: 13" \
-H "Upload-Metadata: filename aGVsbG8udHh0" \
-X POST http://localhost:6060/api/tusupload/Order/42
# expect: 201 Created, Location: /api/tusupload/Order/42/<fid>
# 2) chunk
curl -i -u user:pass \
-H "Tus-Resumable: 1.0.0" \
-H "Upload-Offset: 0" \
-H "Content-Type: application/offset+octet-stream" \
--data-binary "Hello, world!" \
-X PATCH http://localhost:6060/api/tusupload/Order/42/<fid>
# expect: 204 No Content, Upload-Offset: 13Upload-Metadata is a space-separated list of key <base64(value)> pairs. Standard keys: filename, filetype. Free-form keys flow through to FileUploadHandler.generateFilePath and onComplete’s metadata map.
9. Testing#
Minimum smoke test:
@ActiveProfiles("test")
@TestPropertySource(properties = "palmyra.tus.upload-location=${java.io.tmpdir}/my-tus-test")
@Import(MyTusSmokeTest.TestBeans.class)
class MyTusSmokeTest extends AbstractHandlerTest {
@Autowired MyAttachmentJpaRepo repo;
@Value("${palmyra.tus.upload-location}") String uploadRoot;
@TusUpload("Order")
static class TestHandler implements FileUploadHandler {
@Override
public String generateFilePath(FileOwnerInfo o, String n, Map<String,String> m) {
return o.getDefaultFilePath(n == null ? "x.bin" : n);
}
}
@TestConfiguration
static class TestBeans {
@Bean FileUploadHandler testHandler() { return new TestHandler(); }
}
@BeforeEach
void clearDisk() throws Exception {
Path root = Paths.get(uploadRoot);
if (Files.exists(root)) Files.walk(root)
.sorted(Comparator.reverseOrder())
.forEach(p -> { try { Files.deleteIfExists(p); } catch (Exception ignore) {} });
}
@Test
void create_and_patch_round_trips() throws Exception {
byte[] body = "Hello, world!".getBytes(UTF_8);
MvcResult created = mvc.perform(post("/api/tusupload/Order/42")
.header("Tus-Resumable", "1.0.0")
.header("Upload-Length", String.valueOf(body.length))
.header("Upload-Metadata", "filename " + b64("hello.txt")))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andReturn();
String fid = created.getResponse().getHeader("Location")
.substring(created.getResponse().getHeader("Location").lastIndexOf('/') + 1);
mvc.perform(patch("/api/tusupload/Order/42/" + fid)
.characterEncoding((String) null)
.header("Tus-Resumable", "1.0.0")
.header("Upload-Offset", "0")
.header("Content-Type", "application/offset+octet-stream")
.content(body))
.andExpect(status().isNoContent());
assertThat(repo.findByFid(fid)).isPresent();
Path file = Paths.get(uploadRoot).resolve(repo.findByFid(fid).get().getPath());
assertThat(Files.readAllBytes(file)).isEqualTo(body);
}
static String b64(String s) {
return Base64.getEncoder().encodeToString(s.getBytes(UTF_8));
}
}The trick is .characterEncoding((String) null) + raw Content-Type header on PATCH — see gotcha #1 below.
10. Gotchas#
Content-Typemust be exactlyapplication/offset+octet-stream. Spring’sCharacterEncodingFilterappends;charset=UTF-8by default and TUS rejects with 406. Two fixes:- Disable the filter in your test profile:
server.servlet.encoding.enabled: false. - In MockMvc tests, use
.header("Content-Type", "application/offset+octet-stream")+.characterEncoding((String) null).
- Disable the filter in your test profile:
@TusUploadtargets TYPE, not METHOD. In@TestConfiguration@Beanmethods, return an instance of a named nested class that carries the annotation — don’t annotate the@Beanmethod itself.- Registry is lazy. First
resolve(subType)scans the context — don’t expect startup-time failures if you forgot to register a handler. You’ll see anIllegalArgumentExceptionon the first TUS request against that subType. - ID assignment is the storage service’s job. The stock TUS POST handler leaves
info.getId() == null— yourIndexStorageService.createmust callidFactory.createId()before persisting. The reference impl does this. - Context-path interaction. If you set
server.servlet.context-pathANDpalmyra.servlet.prefix-path, both prefixes stack — e.g./{context}/{prefix}/tusupload/.... Prefer lettingprefix-pathdo all the work; leavecontext-pathunset. @EnableSchedulingis required for the expiration sweeper to fire; without it the sweeper bean is inert.- Locks directory — the disk-locking service creates
{upload-location}/lockslazily on first write. A first-boot sweep on an empty install logs aNoSuchFileExceptionwarning once; ignorable.
11. Troubleshooting#
| Symptom | Likely cause | Fix |
|---|---|---|
404 on POST /api/tusupload/... |
palmyra.servlet.prefix-path doesn’t match the URL, or server.servlet.context-path is double-prefixing |
Hit /{context-path}/{prefix}/tusupload/...; ideally set only one of the two |
| 406 on PATCH | Content-Type has ;charset=UTF-8 |
See gotcha #1 |
500 + IllegalArgumentException: No FileUploadHandler registered for subType: X |
No @TusUpload("X") bean in the context |
Add one, or double-check the spelling matches the URL path variable |
500 + NullPointerException ... UploadId on POST |
IndexStorageService.create forgot to info.setId(idFactory.createId()) |
Fix the impl (or copy the reference) |
| Stale uploads never cleaned up | @EnableScheduling missing on main class |
Add it |
Client gets FileAlreadyExistsException on re-POST with same ciId/filename |
Default path layout = ciType/ciId/filename and the file already exists on disk from a previous run |
Override generateFilePath to include a timestamp/uuid segment, or clear the disk between test runs |