Create and Connect a Spring Application to Amazon S3 Object Storage

This guide describes how to create a Spring Boot 3 application that demonstrates how to store, retrieve, and delete user profile pictures in Amazon Web Services (AWS) S3 Object Storage using the Micronaut Object Storage API.

The Micronaut Object Storage API provides a uniform API to create, read and delete objects in the major cloud providers:

  • Amazon S3
  • Google Cloud Storage
  • Oracle Cloud Infrastructure Object Storage

Using this API enables the creation of truly multicloud, portable applications.

Prerequisites #

Follow the steps below to create the application from scratch. However, you can also download the completed example in Java:

A note regarding your development environment

Consider using Visual Studio Code that provides native support for developing applications with the Graal Cloud Native Tools extension.

Note: If you use IntelliJ IDEA, enable annotation processing.

Windows platform: The GCN guides are compatible with Gradle only. Maven support is coming soon.

1. Create the Spring Boot Application #

Generate a Spring Boot application using Spring Initializr with Spring Web.

1.1. Add Dependencies #

Add the following dependencies to your build file as described in Using the Micronaut Spring Boot Starter to use Micronaut Features within a Spring Boot application.

build.gradle

Add the Oracle Public Repository to the repositories block:

maven { url "https://maven.oracle.com/public" }

Add the following dependencies:

implementation enforcedPlatform("cloud.graal.gcn:gcn-bom:4.2.1.2")
implementation platform("io.micronaut.platform:micronaut-platform:4.2.1-oracle-00001")
annotationProcessor platform('io.micronaut.platform:micronaut-platform:4.2.1-oracle-00001')
annotationProcessor("io.micronaut:micronaut-inject-java")
implementation("io.micronaut.spring:micronaut-spring-boot-starter")

pom.xml

Add the Micronaut version to the properties element:

<micronaut.version>4.2.1-oracle-00001</micronaut.version>
<micronaut.platform.version>4.2.1-oracle-00001</micronaut.platform.version>

Add the following repositories element:

<repositories>
   <repository>
      <id>gcn</id>
      <url>https://maven.oracle.com/public</url>
   </repository>
   <repository>
      <id>central</id>
      <url>https://repo.maven.apache.org/maven2</url>
   </repository>
</repositories>

Add the following dependencyManagement element:

<dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-framework-bom</artifactId>
        <version>${spring-framework.version}</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
      <dependency>
        <groupId>cloud.graal.gcn<</groupId>
        <artifactId>gcn-bom</artifactId>
        <version>4.2.1.2</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
      <dependency>
        <groupId>io.micronaut.platform</groupId>
        <artifactId>micronaut-platform</artifactId>
        <version>${micronaut.platform.version}</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
</dependencyManagement>

Add the following plugin to the build/plugins element:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-compiler-plugin</artifactId>
  <configuration>
    <annotationProcessorPaths combine.children="append">
      <path>
        <groupId>io.micronaut</groupId>
        <artifactId>micronaut-inject-java</artifactId>
        <version>${micronaut.version}</version>
      </path>
    </annotationProcessorPaths>
    <compilerArgs>
      <arg>-Amicronaut.processing.group=com.example</arg>
      <arg>-Amicronaut.processing.module=gcnguide</arg>
    </compilerArgs>
  </configuration>
</plugin>
</plugins>

Add these dependencies to your dependencies element:

<dependency>
  <groupId>io.micronaut.spring</groupId>
  <artifactId>micronaut-spring-boot-starter</artifactId>
  <scope>compile</scope>
</dependency>

Additionally, add the following dependency to use Micronaut AWS Object Storage:

build.gradle

implementation("io.micronaut.objectstorage:micronaut-object-storage-aws")

pom.xml

<dependency>
    <groupId>io.micronaut.objectstorage</groupId>
    <artifactId>micronaut-object-storage-aws</artifactId>
    <scope>compile</scope>
</dependency>

1.2. Enable Micronaut #

Add the @EnableMicronaut annotation for Spring Boot to start the Micronaut context. Edit the file named src/main/java/com/example/Application.java as follows:

package com.example;

import io.micronaut.spring.boot.starter.EnableMicronaut;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@EnableMicronaut
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

1.3. Create a Service #

Define a service that interacts with Object Storage in a file named src/main/java/com/example/PictureService.java, as follows:

package com.example;

import io.micronaut.objectstorage.ObjectStorageEntry;
import io.micronaut.objectstorage.ObjectStorageException;
import io.micronaut.objectstorage.ObjectStorageOperations;
import io.micronaut.objectstorage.request.UploadRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.io.InputStream;
import java.util.Optional;

@Service
public class PictureService {

    private static final Logger LOG = LoggerFactory.getLogger(PictureService.class);
    private ObjectStorageOperations<?, ?, ?> pictureBucket;

    public PictureService(ObjectStorageOperations<?,?,?> pictureBucket) { // <1>
        this.pictureBucket = pictureBucket;
    }

    public boolean upload(String name, InputStream inputStream) {
        try {
            pictureBucket.upload(UploadRequest.fromBytes(inputStream.readAllBytes(), name)); // <2> <3>
            return true;
        } catch (IOException | ObjectStorageException ex) {
            LOG.error("Failed to upload picture: " + name, ex);
            return false;
        }
    }

    public Optional<InputStream> download(String name) {
        try {
            return pictureBucket.retrieve(name).map(ObjectStorageEntry::getInputStream); // <4>
        } catch (ObjectStorageException ex) {
            LOG.error("Failed to download picture: " + name, ex);
            return Optional.empty();
        }
    }

    public void delete(String name) {
        pictureBucket.delete(name); // <5>
    }
}

1 Parent interface that enables you to use the API in a generic way for all cloud providers.

2 You can use any of the UploadRequest static methods to build an upload request.

3 The upload operation returns an UploadResponse, which wraps the cloud-specific SDK response object.

4 The retrieve operation returns an ObjectStorageEntry, from which you can get an InputStream. There is also a getNativeEntry() method that gives you access to the cloud vendor-specific response object.

5 The delete operation returns the cloud vendor-specific delete response object in case you need it.

1.4. Create a Controller #

Create a Controller in a file named src/main/java/com/example/PictureController.java:

package com.example;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.Optional;

@Controller
public class PictureController {

    private final static List<String> ALLOWED_UPLOAD_EXTENSIONS = List.of("png", "jpg", "jpeg");
    private static final String PICTURE = "picture.png"; // <1>
    private static final String MISSING_PICTURE = "default.png"; // <2>

    private PictureService pictureService;

    public PictureController(PictureService pictureService) {
        this.pictureService = pictureService;
    }

    @GetMapping
    public String index() {
        return "index.html";
    }

    @GetMapping("/download")
    @ResponseBody
    public byte[] download() throws IOException {
        try(InputStream is = pictureService.download(PICTURE).orElse(getResourceAsStream(MISSING_PICTURE))) {
            return is.readAllBytes();
        } 
    }

    @PostMapping ("/upload")
    public ResponseEntity<String> upload(@RequestParam("file") MultipartFile file) throws IOException {

        if(file != null) {
            Optional<String> extension = Optional.ofNullable(file.getOriginalFilename())
                    .filter(f -> f.contains("."))
                    .map(f -> f.substring(file.getOriginalFilename().lastIndexOf(".") + 1));

            if(extension.isPresent() && ALLOWED_UPLOAD_EXTENSIONS.contains(extension.get())) {
                if(pictureService.upload(PICTURE, file.getInputStream())) {
                    return new ResponseEntity<>(file.getOriginalFilename() + " was successfully uploaded", HttpStatus.OK);
                }
            }
        }

        return new ResponseEntity<>("Could not upload picture", HttpStatus.BAD_REQUEST);
    }

    @DeleteMapping("/delete")
    @ResponseBody
    public void delete() {
        pictureService.delete(PICTURE);
    }

    private InputStream getResourceAsStream(String resourceName) {
        return getClass().getClassLoader().getResourceAsStream(resourceName);
    }
}

1 Name of the ‘object’ being stored, used for all operations (upload, download and delete).

2 Name of the default picture in src/main/resources. (Not essential for the application to run.)

1.5. Create an HTML Page #

Create a simple HTML page to display the image in a browser. Create a new file named src/main/resources/static/index.html with the following contents:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Object Storage Example</title>
  </head>
  <body>
    <h1>Spring Boot Example using Object Storage</h1>
    <div>
      <img src="/download">
    </div>
  </body>
</html>

1.6. Write Tests to Verify Application Logic #

Create a test class for the controller in src/test/java/com/example/PictureControllerTest.java, as follows:

package com.example;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc;

import java.io.InputStream;
import java.util.Optional;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(PictureController.class)
class PictureControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private PictureService service;

    @Test
    void testUpload() throws Exception {

        byte[] bytes = getResource().readAllBytes();

        MockMultipartFile multipartFile = new MockMultipartFile("file", "test.png", null, bytes);

        given(service.upload(eq("picture.png"), any(InputStream.class))).willReturn(true);

        mockMvc.perform(multipart("/upload")
                .file(multipartFile)).andExpect(status().isOk());
    }

    @Test
    void testDownload() throws Exception {

        Optional<InputStream> resource = Optional.of(getResource());
        given(service.download("picture.png")).willReturn(resource);

        mockMvc.perform(get("/download"))
                .andExpect(status().isOk())
                .andExpect(header().longValue("Content-Length", getResource().readAllBytes().length));
    }

    private InputStream getResource() {
        return getClass().getClassLoader().getResourceAsStream("my_picture.png"); // <1>
    }
}

1 If you are not using the downloaded example, add an image file named my_picture.png to the src/main/resources/ directory.

2. Set up AWS Resources #

Start with creating an administrator account, then create a bucket.

2.1. Create an Administrator Account #

Instead of using your AWS root account, use an administrator account. If you do not have one already, see Setting up Your Cloud Accounts.

2.2. Create a Bucket #

Create an Amazon S3 bucket using the aws CLI (assuming that your default region is us-east-1):

aws s3api create-bucket --bucket gcn-guide-object-storage --region=us-east-1

Bucket names must be unique across all AWS accounts.

If you want to create the bucket in a region other than us-east-1, you need additional parameters. For example, to create the bucket in the eu-west-3 region, use the following command:

aws s3api create-bucket \
    --bucket gcn-guide-object-storage \
    --region eu-west-3 \
    --create-bucket-configuration LocationConstraint=eu-west-3

Then, configure the bucket in src/main/resources/application.properties:

micronaut.object-storage.aws.default.bucket=gcn-guide-object-storage

Note: Your default region is automatically detected from the configuration you created using aws configure.

2.3. Configure the Upload Parameters #

If you want to upload a file larger than 1MB, you need to configure this in the src/main/resources/application.properties file as follows:

# 20 * 1024 * 1024 = 20MB
micronaut.server.multipart.max-file-size: 20971520

3. Run the Tests (Optional) #

If you are not using the downloaded example, add an image file named my_picture.png to the src/main/resources/ directory.

Use the following command to run the test.

./gradlew test

Then open the file build/reports/tests/test/index.html in a browser to view the results.

./mvnw test

4. Run the Application #

To run the application, use the following command, which starts the application on port 8080.

./gradlew bootRun
./mvnw spring-boot:run

5. Test the Application #

Test the application by uploading, downloading, and deleting a user profile picture.

5.1. Upload a Picture #

If using the complete solution, navigate into the src/main/resources/ directory before running the following command. Otherwise, update the my_picture.png file with an image you want to upload.

curl -i -F "file=@my_picture.png" http://localhost:8080/upload

Use the aws CLI to verify that the file has been uploaded to an S3 bucket, as follows:

aws s3 ls s3://gcn-guide-object-storage/

5.2. View the Website #

Open a browser to http://localhost:8080 and you should see:

spring-object-storage-website

5.3. Download the Picture #

Use the following command to download a picture:

curl http://localhost:8080/download --raw -o image.png

5.4. Delete the Picture #

Use the following command to delete a picture:

curl -X "DELETE" http://localhost:8080/delete

Then, check that the file has actually been deleted using the following command:

aws s3 ls s3://gcn-guide-object-storage/

6. Clean up #

When you have completed the guide, remove the bucket from S3 to avoid stale resources. Use the following command to delete the bucket:

aws s3api delete-bucket --bucket gcn-guide-object-storage --region us-east-1

Summary #

This guide demonstrated how to create a Spring Boot application to store, retrieve, and delete user profile pictures in Amazon Web Services S3 Object Storage.