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 in fid).
  • When offset == length, call handler.onComplete with the just-saved row.
  • Keep getRelativePath side-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 TusMgmtProperties

See 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: 13

Upload-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#

  1. Content-Type must be exactly application/offset+octet-stream. Spring’s CharacterEncodingFilter appends ;charset=UTF-8 by 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).
  2. @TusUpload targets TYPE, not METHOD. In @TestConfiguration @Bean methods, return an instance of a named nested class that carries the annotation — don’t annotate the @Bean method itself.
  3. 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 an IllegalArgumentException on the first TUS request against that subType.
  4. ID assignment is the storage service’s job. The stock TUS POST handler leaves info.getId() == null — your IndexStorageService.create must call idFactory.createId() before persisting. The reference impl does this.
  5. Context-path interaction. If you set server.servlet.context-path AND palmyra.servlet.prefix-path, both prefixes stack — e.g. /{context}/{prefix}/tusupload/.... Prefer letting prefix-path do all the work; leave context-path unset.
  6. @EnableScheduling is required for the expiration sweeper to fire; without it the sweeper bean is inert.
  7. Locks directory — the disk-locking service creates {upload-location}/locks lazily on first write. A first-boot sweep on an empty install logs a NoSuchFileException warning 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