Attachments

This guide covers proxying attachment operations — upload, download, signed URLs, and deletion — from your agent app to the Memory Service.

New to attachment concepts? Read Attachments first to understand the attachment lifecycle, external URL references vs server-stored files, and how attachments link to conversation entries. This guide focuses on the Spring implementation.

Prerequisites

Starting checkpoint: This guide starts from java/spring/examples/doc-checkpoints/03-with-history

Make sure you’ve completed the previous guides:

Why Proxy Attachments?

Agent apps mediate all interactions between end users and the Memory Service. Your frontend cannot call the Memory Service directly — it sends requests to your agent app, which forwards them with proper authentication.

The MemoryServiceProxy helper already handles the complexity of multipart uploads, binary downloads, redirect handling, and bearer token propagation for attachments. You just need to create thin controllers that delegate to it:

  • AttachmentsProxyController — Authenticated proxy for upload, download, download-url, and delete
  • AttachmentDownloadProxyController — Unauthenticated proxy for signed download URLs (used by browser <img>, <audio>, <video> tags)

Add Attachment Proxy Endpoints

Create AttachmentsProxyController.java to proxy authenticated attachment operations to the Memory Service:

AttachmentsProxyController.java
package com.example.demo;

import io.github.chirino.memoryservice.client.MemoryServiceProxy;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

/**
 * Spring REST controller that proxies attachment requests to the memory-service.
 * Delegates all logic to {@link MemoryServiceProxy}.
 */
@RestController
@RequestMapping("/v1/attachments")
class AttachmentsProxyController {

    private final MemoryServiceProxy proxy;

    AttachmentsProxyController(MemoryServiceProxy proxy) {
        this.proxy = proxy;
    }

    @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public ResponseEntity<?> upload(
            @RequestPart("file") MultipartFile file,
            @RequestParam(value = "expiresIn", required = false) String expiresIn) {
        return proxy.uploadAttachment(file, expiresIn);
    }

    @GetMapping("/{id}")
    public ResponseEntity<?> retrieve(@PathVariable String id) {
        return proxy.retrieveAttachment(id);
    }

    @GetMapping("/{id}/download-url")
    public ResponseEntity<?> getDownloadUrl(@PathVariable String id) {
        return proxy.getAttachmentDownloadUrl(id);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<?> delete(@PathVariable String id) {
        return proxy.deleteAttachment(id);
    }
}

Each method is a one-liner delegation to MemoryServiceProxy, which handles:

  • WebClient streaming — Uses Spring’s WebClient with exchangeToMono for full control over the response, avoiding buffering large files in memory.
  • Bearer token propagation — Extracts the user’s bearer token and forwards it to the Memory Service for authorization.
  • Redirect handling — The retrieveAttachment() method handles 302 redirects for S3 presigned URLs.
  • Multipart upload — Uses BodyInserters.fromMultipartData() with an InputStreamResource to stream the file through without loading it entirely into memory.

Add Signed Download Proxy

Browser elements like <img>, <audio>, and <video> tags cannot send Authorization headers. The Memory Service solves this with signed download URLs that embed a time-limited token in the URL path. This proxy forwards those unauthenticated requests.

Create AttachmentDownloadProxyController.java:

AttachmentDownloadProxyController.java
package com.example.demo;

import io.github.chirino.memoryservice.client.MemoryServiceProxy;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * Unauthenticated proxy that forwards signed download requests to the memory-service. The signed
 * token in the URL path provides authorization — no bearer token is required.
 */
@RestController
@RequestMapping("/v1/attachments/download")
class AttachmentDownloadProxyController {

    private final MemoryServiceProxy proxy;

    AttachmentDownloadProxyController(MemoryServiceProxy proxy) {
        this.proxy = proxy;
    }

    @GetMapping("/{token}/{filename}")
    public ResponseEntity<?> download(@PathVariable String token, @PathVariable String filename) {
        return proxy.downloadAttachmentByToken(token, filename);
    }
}

This endpoint requires no bearer token — the signed token in the URL path provides authorization.

Update Security Configuration

Update SecurityConfig.java to allow unauthenticated access to the signed download path:

SecurityConfig.java
package com.example.demo;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf(csrf -> csrf.disable())
                .authorizeHttpRequests(
                        auth ->
                                auth.requestMatchers("/ready")
                                        .permitAll()
                                        // Signed download URLs are unauthenticated
                                        // (token in URL provides authorization)
                                        .requestMatchers("/v1/attachments/download/**")
                                        .permitAll()
                                        .anyRequest()
                                        .authenticated())
                .oauth2Login(oauth2 -> oauth2.defaultSuccessUrl("/", false))
                .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> {}))
                .logout(logout -> logout.logoutSuccessUrl("/"));
        return http.build();
    }
}

The key change is adding .requestMatchers("/v1/attachments/download/**").permitAll() before .anyRequest().authenticated(). This allows signed download URLs to work without an Authorization header, while all other endpoints still require authentication.

Testing

Make sure you define the bearer token helper:

function get-token() {
  curl -sSfX POST http://localhost:8081/realms/memory-service/protocol/openid-connect/token \
    -H "Content-Type: application/x-www-form-urlencoded" \
    -d "client_id=memory-service-client" \
    -d "client_secret=change-me" \
    -d "grant_type=password" \
    -d "username=bob" \
    -d "password=bob" \
    | jq -r '.access_token'
}

Upload an Attachment

echo "Hello, attachments!" > /tmp/test-upload.txt
curl -sSfX POST http://localhost:9090/v1/attachments \
  -H "Authorization: Bearer $(get-token)" \
  -F "file=@/tmp/test-upload.txt" | jq

Example output:

{
  "id": "96618a21-c7fc-41c6-ba71-b1d83e3ecb95",
  "href": "/v1/attachments/96618a21-c7fc-41c6-ba71-b1d83e3ecb95",
  "contentType": "text/plain",
  "filename": "test-upload.txt",
  "size": 21,
  "sha256": "a1b2c3d4...",
  "expiresAt": "2025-01-28T11:30:00Z",
  "status": "ready"
}

Save the attachment ID for subsequent commands:

ATTACHMENT_ID="<id from the response above>"

Get a Signed Download URL

curl -sSfX GET "http://localhost:9090/v1/attachments/${ATTACHMENT_ID}/download-url" \
  -H "Authorization: Bearer $(get-token)" | jq

Example output:

{
  "contentType": "text/plain",
  "expiresAt": "2026-03-06T15:59:24Z",
  "filename": "test-upload.txt",
  "href": "/v1/attachments/a06af9bd-43a3-47a6-bbc5-d46aae03e36c",
  "id": "a06af9bd-43a3-47a6-bbc5-d46aae03e36c",
  "sha256": "45756124416f43c4c9c5138fb2909cea45ae20b9852e9def468370906be6c84a",
  "size": 20,
  "status": "ready"
}

Download via Signed URL (No Auth Required)

Use the url from the previous response — no Authorization header needed:

curl -sSf "http://localhost:9090${DOWNLOAD_URL}"

Example output:

{
  "expiresIn": 300,
  "url": "/v1/attachments/download/MTcwNTV8MTc3MjgwOTQ2NA.DUpxA9YbqkQBTz943OIRA9V5zfLoT0t2Z7rX0DcGj3U/test-upload.txt"
}

Delete an Attachment

curl -sSfX DELETE "http://localhost:9090/v1/attachments/${ATTACHMENT_ID}" \
  -H "Authorization: Bearer $(get-token)"

Next Steps