A Developer’s Guide to Integrating MinIO with Angular and Spring Boot

Alexander Kapincev
9 min readNov 16, 2023

--

Step 1: Project Setup

Setting Up MinIO with Docker-Compose

In this guide, we will walk through the process of setting up MinIO with Docker, integrating it with a Spring Boot backend, and creating an Angular frontend to manage books including their covers.

MinIO is a high-performance, S3-compatible object storage server ideal for development and production. To ensure security in production, configure your MinIO instance with proper security measures.

Setting Up MinIO with Docker-Compose

Create a docker-compose.yml file with the following content to set up MinIO:

version: '3.8'
services:
minio:
image: docker.io/bitnami/minio:2022
ports:
- '9000:9000'
- '9001:9001'
networks:
- minionetwork
volumes:
- 'minio_data:/data'
environment:
- MINIO_ROOT_USER=admin
- MINIO_ROOT_PASSWORD=password
- MINIO_DEFAULT_BUCKETS=book-covers
networks:
minionetwork:
driver: bridge
volumes:
minio_data:
driver: local

Running MinIO

To run MinIO, execute the following command in your terminal within the directory containing your docker-compose.yml file:

docker-compose up -d

This command starts MinIO. You can access the MinIO web interface by navigating to http://localhost:9001 in your web browser.

Step 2: Spring Boot Backend Setup

We’ll create a Spring Boot project that will serve as our backend service, providing endpoints for uploading and managing book data, including images.

Initial Project Setup

Generate Project: Use Spring Initializr to generate a Spring Boot project with the following dependencies:

  • Spring Web
  • Spring Data JPA
  • H2 (or any other database driver of your choice)
  • Lombok (for reducing boilerplate code)

MinIO Configuration

Add MinIO Dependency: Add the MinIO client dependency to your pom.xml or build.gradle file.

For Maven, add:

<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.7</version>
</dependency>

For Gradle, add:

implementation 'io.minio:minio:8.5.7'

Application Properties: Configure MinIO properties in application.properties or application.yml.

minio.access.key=admin
minio.secret.key=password
minio.url=http://localhost:9000
minio.bucket.name=book-covers

MinIO Configuration Class: Create a MinioConfig class that sets up the MinioClient.

@Configuration
public class MinioConfig {

@Value("${minio.access.key}")
private String accessKey;

@Value("${minio.secret.key}")
private String secretKey;

@Value("${minio.url}")
private String minioUrl;

@Bean
public MinioClient minioClient() {
return MinioClient.builder()
.endpoint(minioUrl)
.credentials(accessKey, secretKey)
.build();
}
}

CORS Configuration in Backend

When setting up a full-stack application with separate frontend and backend services, it’s crucial to configure Cross-Origin Resource Sharing (CORS) properly. CORS is a security feature that restricts web applications from making requests to domains different from the one that served the web application.

The WebConfig class in your backend is specifically tailored to address this. By defining a WebMvcConfigurer bean with CORS mappings, you're instructing your Spring Boot application to allow incoming HTTP requests from your Angular application's domain.

@Configuration
public class WebConfig {

@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(@NotNull CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:4200")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true);
}
};
}
}

Book Entity and Repository

Create Book Entity: Define a Book entity with fields such as title, author, description, and image URL.

@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Book {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String author;
private String description;
private String imageUrl;
// Getters and setters are handled by Lombok
}

Create Book Repository: Use Spring Data JPA to create a repository interface for your Book entity.

public interface BookRepository extends JpaRepository<Book, Long> {
// Custom query methods if needed
}

We’ll start with the image storage service that will handle uploading images to MinIO and then move on to implementing the service and controller methods for the Book entity.

Implementing the Image Storage Service

Here’s the implementation of the ImageStorageService:

@Service
@RequiredArgsConstructor
public class ImageStorageService {

private final MinioClient minioClient;

@Value("${minio.bucket.name}")
private String bucketName;

@Value("${minio.url}")
private String minioUrl;

public String uploadImage(MultipartFile file) {
String fileName = generateFileName(file);
try (InputStream is = file.getInputStream()) {
minioClient.putObject(
PutObjectArgs.builder()
.bucket(bucketName)
.object(fileName).stream(is, file.getSize(), -1)
.contentType(file.getContentType())
.build());
return minioUrl + "/" + bucketName + "/" + fileName;
} catch (Exception e) {
throw new RuntimeException("Failed to store image file.", e);
}
}

private String generateFileName(MultipartFile file) {
return new Date().getTime() + "-" + Objects.requireNonNull(file.getOriginalFilename()).replace(" ", "_");
}
}

This service provides a method to upload a file to MinIO and return the URL of the stored file. It also includes a helper method to generate a unique file name for each uploaded file.

Implementing the Book Service

Next, let’s define the BookService which will use ImageStorageService to handle the image upload:

@Service
@RequiredArgsConstructor
public class BookService {

private final BookRepository bookRepository;

private final ImageStorageService imageStorageService;

public Book saveBook(Book book, MultipartFile imageFile) {
try {
String imageUrl = imageStorageService.uploadImage(imageFile);
book.setImageUrl(imageUrl);
return bookRepository.save(book);
} catch (Exception e) {
throw new RuntimeException("Failed to save book.", e);
}
}

public List<Book> getAllBooks() {
return bookRepository.findAll();
}
}

The saveBook method in BookService handles the logic to save book information along with the image. It uploads the image first and then saves the book entity with the URL of the uploaded image. The getAllBooks method is much simpler. It fetches and returns a list of all books from the repository, providing a straightforward way to retrieve book data.

Implementing the Book Controller

The last step in our backend setup is to implement the BookController. This controller is crucial as it provides the interface through which the outside world, namely our Angular frontend, interacts with our application.

The BookController is equipped with two endpoints:

@RestController
@RequestMapping("/books")
@RequiredArgsConstructor
public class BookController {

private final BookService bookService;

@PostMapping(consumes = { MediaType.MULTIPART_FORM_DATA_VALUE })
public ResponseEntity<Book> createBook(@RequestPart("book") Book book,
@RequestPart("image") MultipartFile image) {
Book savedBook = bookService.saveBook(book, image);
return new ResponseEntity<>(savedBook, HttpStatus.CREATED);
}

@GetMapping
public ResponseEntity<List<Book>> getAllBooks() {
List<Book> allBooks = bookService.getAllBooks();
return new ResponseEntity<>(allBooks, HttpStatus.OK);
}
}
  1. Creating a New Book: This endpoint is designed to handle POST requests that include multipart/form-data. The data consists of book details in JSON format and an image file. When a request hits this endpoint, the BookController passes the information to the BookService to save the book and its image.
  2. Listing All Books: The second endpoint is a GET request that triggers the BookService to return a list of all books stored in our system. It’s a straightforward way to fetch book data for display on the frontend.

Step 3: Angular Frontend Setup

For the Angular Frontend setup, we’ll construct an application that offers a user-friendly interface to add new books, complete with the functionality to upload images for each book.

Initial Project Setup

Create a New Angular Project: Use Angular CLI to set up a new project if you haven’t already:

ng new book-manager-frontend

Form and Service for Book Creation

Create the Book Model: Define a model to represent the book data in src/app/models/book.model.ts.

export interface Book {
id?: number;
title: string;
author: string;
description: string;
imageUrl?: string;
}

Create the Book Service: Generate a service to handle communication with the backend:

ng generate service services/book

Implement the service in src/app/services/book.service.ts:

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { Book } from '../models/book.model';

@Injectable({
providedIn: 'root'
})
export class BookService {

private baseUrl = 'http://localhost:8080/books';

constructor(private http: HttpClient) { }

createBook(book: Book, image: File): Observable<Book> {
const formData: FormData = new FormData();
formData.append('book', new Blob([JSON.stringify(book)], {
type: 'application/json'
}));
formData.append('image', image);
return this.http.post<Book>(this.baseUrl, formData);
}
getBooks(): Observable<Book[]> {
return this.http.get<Book[]>(this.baseUrl);
}
}

Implement the Book Form Component: In src/app/book-form/book-form.component.ts, create the form group and handle the image selection:

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import {FormBuilder, FormGroup, ReactiveFormsModule} from '@angular/forms';
import {BookService} from '../services/book.service';
import {MatInputModule} from '@angular/material/input';
import {MatButtonModule} from '@angular/material/button';
import {HttpClientModule} from '@angular/common/http';

@Component({
selector: 'app-book-form',
standalone: true,
imports: [CommonModule, MatInputModule, ReactiveFormsModule, MatButtonModule, HttpClientModule],
templateUrl: './book-form.component.html',
styleUrl: './book-form.component.css'
})
export class BookFormComponent {

bookForm: FormGroup;

selectedImage: File | undefined;

constructor(private fb: FormBuilder, private bookService: BookService) {
this.bookForm = this.fb.group({
title: [''],
author: [''],
description: ['']
});
}

onImageSelected(event: Event): void {
const element = event.currentTarget as HTMLInputElement;
let fileList: FileList | null = element.files;
if (fileList) {
this.selectedImage = fileList[0];
}
}

onSubmit(): void {
if (this.bookForm.valid && this.selectedImage) {
this.bookService.createBook(this.bookForm.value, this.selectedImage).subscribe({
next: (book) => {
// Handle the response
},
error: (err) => {
// Handle errors
}
});
}
}
}

Update the Book Form Template: Modify the template in src/app/book-form/book-form.component.html to include form fields and a file input for the image:

<form [formGroup]="bookForm" (ngSubmit)="onSubmit()" class="space-y-4">
<div class="w-full md:w-1/2 px-3 mb-6 md:mb-0">
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="title">
Title
</label>
<input class="appearance-none block w-full bg-gray-200 text-gray-700 border border-gray-200 rounded py-3 px-4 mb-3 leading-tight focus:outline-none focus:bg-white focus:border-gray-500" id="title" type="text" formControlName="title">
</div>
<div class="w-full md:w-1/2 px-3">
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="author">
Author
</label>
<input class="appearance-none block w-full bg-gray-200 text-gray-700 border border-gray-200 rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500" id="author" type="text" formControlName="author">
</div>
<div class="w-full px-3">
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="description">
Description
</label>
<textarea class="appearance-none block w-full bg-gray-200 text-gray-700 border border-gray-200 rounded py-3 px-4 mb-3 leading-tight focus:outline-none focus:bg-white focus:border-gray-500" id="description" formControlName="description"></textarea>
</div>
<div class="w-full px-3">
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="image">
Image
</label>
<input type="file" (change)="onImageSelected($event)" id="image" class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-gray-50 file:text-gray-700 hover:file:bg-gray-100">
</div>
<div class="w-full px-3">
<button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">
Create Book
</button>
</div>
</form>

This sets up a basic form for creating a book, including uploading an image, in your Angular application.

Implementing the BookListComponent in Angular is straightforward and serves as a fundamental feature for our book management application. This component will be responsible for fetching and displaying a list of books from our Spring Boot backend.

Here’s how you can set up the BookListComponent.

Implement the Book List Component: In src/app/book-list/book-list.component.ts, you'll inject BookService and use it to fetch and display the books:

@Component({
selector: 'app-book-list',
standalone: true,
imports: [CommonModule, MatCardModule, HttpClientModule],
templateUrl: './book-list.component.html',
styleUrl: './book-list.component.css'
})
export class BookListComponent implements OnInit {
books: Book[] = [];

constructor(private bookService: BookService) {}
ngOnInit(): void {
this.bookService.getBooks().subscribe({
next: (data) => {
this.books = data;
},
error: (err) => {
console.error('Error fetching books', err);
}
});
}
}

Create the Template for the BookList Component: In src/app/book-list/book-list.component.html, use Angular's structural directives to display the list:

<div class="container mx-auto px-4">
<div *ngIf="books.length; else noBooksTemplate" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div *ngFor="let book of books" class="max-w-sm rounded overflow-hidden shadow-lg bg-white">
<img class="w-full" *ngIf="book.imageUrl" [src]="book.imageUrl" alt="{{ book.title }}">
<div class="px-6 py-4">
<div class="font-bold text-xl mb-2">{{ book.title }}</div>
<p class="text-gray-700 text-base">
{{ book.description }}
</p>
</div>
<div class="px-6 pt-4 pb-2">
<span class="inline-block bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2 mb-2">{{ book.author }}</span>
</div>
</div>
</div>
<ng-template #noBooksTemplate>
<p class="text-center text-gray-500 text-xs">
No books available.
</p>
</ng-template>
</div>

Finally we should update our AppComponent.

Update the AppComponent Template

Modify src/app/app.component.html to include a navigation bar and a main content area where the book list and form will be displayed:

<!-- Navigation -->
<nav class="bg-gray-800 p-4">
<div class="container mx-auto flex items-center justify-between">
<a class="text-white text-3xl font-bold" href="/">Book Manager</a>
<div>
<a class="text-gray-400 hover:text-white px-3 py-2 rounded-md text-sm font-medium" href="/books">Books</a>
<a class="text-gray-400 hover:text-white px-3 py-2 rounded-md text-sm font-medium" href="/add-book">Add Book</a>
</div>
</div>
</nav>
<!-- Main Content -->
<main class="container mx-auto p-4">
<router-outlet></router-outlet>
</main>
<!-- Footer -->
<footer class="bg-gray-800 p-4 mt-8">
<div class="container mx-auto text-center text-white">
&copy; Book Manager. All rights reserved.
</div>
</footer>

If you’re curious to see how the project functions in detail, head over to my GitHub repository for the full code: GitHub Project Link.

--

--