Upload e download no S3 usando URLs pré-assinados

Nesse artigo vamos abordar uma maneira bem interessante de fazer upload e download de arquivos utilizando a própria API do AWS S3.

Fazer operações de upload e download no Amazon S3 é uma tarefa bem simples já que a própria SDK fornecida pela Cloud é bem intuitiva e simples de utilizar, entretanto você pode esbarrar em alguns problemas dependendo da sua necessidade de negócio e quando nos preocupamos com a escalabilidade da nossa aplicação e também a tolerância a falhas.

Como assim a tolerância a falhas e escalabilidade? O que acontece é que as vezes deixamos de analisar o tamanho dos arquivos que estarão envolvidos nas operações e a depender da linguagem utilizada o SDK fornecido pela própria cloud não permite utilizar algumas abordagens como o Stream direto.

Vamos analisar um cenário você precisa de fazer o upload e download de arquivos grandes e temos uma aplicação Spring Boot, bom a abordagem que o SDK nos permite é fazer o upload para aplicação onde deveríamos salvar o objeto localmente e então fazer o upload para o S3, essa abordagem é bem interessante, mas com um arquivo grande pode ser bastante custoso.

Uma forma interessante de fazer isso é usar um recurso disponível pelo próprio serviço, as URLs pré-assinados, nessa abordagem solicitamos ao serviço através do SDK que nos forneça uma url assinada com todos os fatores de segurança necessárias para fazer o upload para um bucket privado e delimitamos um tempo para essa operação, assim a aplicação cliente pode fazer o upload direto para o bucket sem precisar passar pelo nosso serviço Spring.

Implementação

Vamos colocar essa implementação em prática! Essa abordagem pode ser feita com qualquer linguagem utilizando o SDK fornecido pela própria AWS, no nosso exemplo vamos utilizar uma aplicação Spring Boot, pois o framework é amplamente utilizado.

Configurando as dependências

Para o nosso exemplo vamos utilizar também o Spring Cloud, que nos fornece uma integração bem interessante entre o ecossistema do framework e do SDK fornecido pela AWS.

Utilizando um projeto Maven vamos precisar adicionar as seguintes informações no arquivo pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	....
	<properties>
		<java.version>17</java.version>
		<spring-cloud.version>3.0.0</spring-cloud.version>
		<aws.java.sdk.version>2.20.43</aws.java.sdk.version>
	</properties>

	<dependencies>
		... outras dependências
		<dependency>
			<groupId>io.awspring.cloud</groupId>
			<artifactId>spring-cloud-aws-starter</artifactId>
		</dependency>
		<dependency>
			<groupId>io.awspring.cloud</groupId>
			<artifactId>spring-cloud-aws-starter-s3</artifactId>
		</dependency>
	</dependencies>

	<dependencyManagement>
		<dependencies>
			<dependency>
				<groupId>io.awspring.cloud</groupId>
				<artifactId>spring-cloud-aws-dependencies</artifactId>
				<version>${spring-cloud.version}</version>
				<type>pom</type>
				<scope>import</scope>
			</dependency>
			<dependency>
				<groupId>software.amazon.awssdk</groupId>
				<artifactId>bom</artifactId>
				<version>${aws-java-sdk.version}</version>
			</dependency>
		</dependencies>
	</dependencyManagement>

	<build>
		... configurações de build
	</build>

</project>
pom.xml

Explicando cada configuração

  • Nas linhas 8 e 9 estamos deixando a propriedade contendo as versões que vamos utilizar, estamos utilizando a última versão estável de cada projeto.
  • Entre as linhas 14 e 21 estamos definindo duas dependências, o primeiro introduz na nossa aplicação o ecossistema do Spring Cloud e o segundo nos fornece o SDK do serviço AWS S3.
  • Entre as linhas 24 e 39 estamos configurando o nosso dependency management que ajudará a selecionar as versões mais compatíveis com base na nossa configuração.

Implementando a classe de serviço do S3

Agora que já temos todas a dependências devidamente instaladas, vamos implementar nossa classe de serviço que será responsável por fazer as operações.

package com.artefatox.cloud;

import java.net.URI;
import java.time.Duration;
import java.util.Map;

import org.springframework.stereotype.Service;

import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest;
import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;

@Service
public class PreSignedUrlGeneratorService {
	
	public String upload(String bucketName, String filename) {
		PutObjectRequest request = PutObjectRequest.builder()
				.bucket(bucketName)
				.key(filename)
				.metadata(Map.of())
				.build();
		
		PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
				.signatureDuration(Duration.ofMinutes(10L))
				.putObjectRequest(request)
				.build();

		S3Presigner presigner = S3Presigner.builder()
				.build();
		
		PresignedPutObjectRequest objectRequest = presigner.presignPutObject(presignRequest);
		return objectRequest.url().toString();
	}

	public String download(String bucketName, String filename) {
		
		GetObjectRequest request = GetObjectRequest.builder()
				.bucket(bucketName)
				.key(filename)
				.build();
		
		GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
				.signatureDuration(Duration.ofMinutes(10L))
				.getObjectRequest(request)
				.build();
		
		S3Presigner presigner = S3Presigner.builder()
				.build();
		
		PresignedGetObjectRequest objectRequest = presigner.presignGetObject(presignRequest);
		return objectRequest.url().toString();
		
	}
}
StorageService.java

A implementação é relativamente simples, criamos métodos que recebem o nome do bucket e também o nome do arquivo que fará o upload, essa parte é bem importante pois o S3 utiliza o conceito de Key, o nome do arquivo é a chave dele, então se na interface você quer ter uma visão parecido com pastas e subpastas é necessário você passar algo com “/pasta/subpasta/nomedoarquivo.extensao”, por razão que o nome do arquivo acaba virando a Key dele é importante que a extensão venha junto desse nome.

Outro ponto interessante para se observar é que estamos definindo que o tempo de expiração das urls serão de 10 minutos, após esse tempo elas ficaram inutilizáveis, esse comportamento está definido nas linhas 28 e 47 para upload e download respectivamente.

Testando nossa implementação

Para testar essa implementação vamos criar um controller que receberá essas informações via REST, vamos criar alguns Records que serviram de DTO (Data Transfer Object) para auxilar nas chamadas:

package com.artefatox.cloud;

public record File(String filename) {

}
File.java
package com.artefatox.cloud;

public record PreSignUrl(String url) {

}
PreSignUrl.java
package com.artefatox.cloud;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/storage")
public class StorageController {
	
	private static final String BUCKET_NAME = "sample";
	private final PreSignedUrlGeneratorService service;
	
	public StorageController(PreSignedUrlGeneratorService service) {
		this.service = service;
	}

	@PostMapping("/upload")
	public PreSignUrl upload(@RequestBody File file) {
		final String url = service.upload(BUCKET_NAME, file.filename());
		return new PreSignUrl(url);
	}
	
	@PostMapping("/download")
	public PreSignUrl download(@RequestBody File file) {
		final String url = service.download(BUCKET_NAME, file.filename());
		return new PreSignUrl(url);
	}

}
StorageController.java

Observe que é uma implementação bem simples, o nome do bucket poderia ficar em um arquivo de propriedade ou uma variável de ambiente, mas decidimos deixar ele escrito na própria classe para ficar mais didático.

Agora antes de testar precisamos, de adicionar as chaves de acesso para que nossa aplicação executando localmente possa acessar os serviços da Amazon Web Services.

spring.cloud.aws.credentials.access-key=AKIAIOSFODNN7EXAMPLE
spring.cloud.aws.credentials.secret-key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
spring.cloud.aws.s3.region=us-east-1
application.properties

Essa configuração pode ser feita direto no application.properties, porque estamos utilizando o Spring Cloud, observe que além das chaves estamos definindo a região de acesso do nosso serviço.

Testando o upload

Agora que tudo está devidamente implementado vamos realizar uma chamada e validar o funcionamento, gerando uma chave para fazer o upload.

curl --request POST \
  --url http://localhost:8080/storage/upload \
  --header 'Content-Type: application/json' \
  --data '{
	"filename": "example.json"
}'
Terminal

Saída do terminal:

{"url":"https://sample.s3.amazonaws.com/example.json?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20240407T005755Z&X-Amz-SignedHeaders=host&X-Amz-Expires=600&X-Amz-Credential=localstack%2F20240407%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Signature=b88f3d1a5b360ed8b8295d53384d99cdb55b547b22ea55da104843fcad46a29b"}
Terminal

Agora podemos utilizar essa URL gerada com o método PUT para fazer o upload do arquivo.

curl --request PUT \
  --url 'https://sample.s3.amazonaws.com/example.json?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20240407T005719Z&X-Amz-SignedHeaders=host&X-Amz-Expires=600&X-Amz-Credential=localstack%2F20240407%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Signature=ceb909d6a890142982d6cebcf608b31cf32174629b3b5e9bf2adf59d6e6aba9f' \
  --header 'Content-Type: application/json' \
  --data ~/arquivos/example.json
Terminal

Caso você esteja utilizando alguma ferramenta de suporte a requisições REST como o Postman ou Insominia você deve colocar o arquivo no corpo da requisição como Binary File.

Testando o download

O download também é simples, basta fazer a requisição para nosso endpoint de download e em seguida utilizar a url gerada, ele é ainda mais simples porque pode ser utilizado com o método GET, então pode utilizar até um browser.

curl --request POST \
  --url http://localhost:8080/storage/download \
  --header 'Content-Type: application/json' \
  --data '{
	"filename": "example.json"
}'
Terminal

Resultado da requisição:

{"url":"https://sample.s3.amazonaws.com/example.json?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20240407T010725Z&X-Amz-SignedHeaders=host&X-Amz-Expires=600&X-Amz-Credential=localstack%2F20240407%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Signature=7952f5f385a3691d53c660f5c3688d100586e6e6cf450501af31f30eaf533198"}
Terminal

Conclusão

Essa abordagem nos oferece inúmeros benefícios pois, você não ficará preso a capacidade da sua aplicação em processar arquivos gigantescos, pois estará usando os recursos da própria cloud, ela também permite consumir os arquivos diretamente no browser como é o caso de uma imagem, que ao acessar a url é exibida diretamente.

Links úteis

Mauricio Lima
Mauricio Lima

Bacharel em Ciência da Computação, profissional dedicado ao desenvolvimento de software e entusiasta da tecnologia.

Artigos: 65