Creating a Spring Boot RESTful Product API
Learn how to create a Spring Boot REST Api in this tutorial. We’ll see how to fetch data from a database and serve it in JSON format.
This example covers essential components such as the model, repository, service, and controller. To keep things simple, we’ll pre-populate the database with some data using pure SQL queries, allowing us to focus on the implementation.
1. Setting Up the Project
We’ll begin by setting up a Spring Boot project using Maven. While creating the project, ensure you include the following essential dependencies:
- Lombok: Simplify your code by reducing repetitive tasks like writing getters, setters, and constructors. With Lombok annotations, your code becomes cleaner and easier to maintain.
- Spring Data JPA: Enables seamless database operations, offering built-in methods for creating, reading, updating, and deleting data, as well as support for custom queries.
- MySQL Driver: Connect and interact with a MySQL database through this driver, ensuring smooth communication between your application and the database.
- Spring Web: Essential for building web applications, including RESTful APIs.
- Hibernate Validator: It ensure data validation in Java applications using annotations.
Learn More About Online Spring Framework Training for getting personalized guidance in creating live projects using spring framework.
The pom.xml
file for this project will look this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 | <?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"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.4.1</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>com.demo</groupId> <artifactId>RestProduct</artifactId> <version>0.0.1-SNAPSHOT</version> <name>RestProduct</name> <description>RestProduct</description> <url/> <licenses> <license/> </licenses> <developers> <developer/> </developers> <scm> <connection/> <developerConnection/> <tag/> <url/> </scm> <properties> <java.version>23</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.36</version> <scope>provided</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <annotationProcessorPaths> <path> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.36</version> </path> </annotationProcessorPaths> </configuration> </plugin> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <excludes> <exclude> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </exclude> </excludes> </configuration> </plugin> </plugins> </build> </project> |
This is how our project directory will look like. It will have multiple components like:-
- Controllers for handling request and responses
- Models for representing the data table in database,
- Repository for DAO operations,
- Services for handling business logics,
- Global Exception class to handle the exceptions in centralized way.
- And, application.properties for setting configurations.
2. Configuring the Application
Now, let’s set up the configuration for our project in the application.properties
file.
- The datasource by specifying the URL, username, password, and driver class.
- Context path, which will serve as a prefix for all our API endpoints.
- Directory where we will upload the product image.
- File types which we will allow user to upload. (*.jpeg, *.png, *.webp)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | spring.application.name=RestProduct spring.datasource.url=jdbc:mysql://localhost:3306/shop?createDatabaseIfNotExist=true spring.datasource.username=root spring.datasource.password=6201 spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true server.servlet.context-path=/api file.upload-dir=E:/Uploads/Product/images file.allowed-types=image/jpeg,image/png,image/webp # file size should be less than 5MB spring.servlet.multipart.max-file-size=5MB # per request size should be less than 30MB spring.servlet.multipart.max-request-size=30MB |
3. Defining the Data Model for product
The product table will hold multiple fields, and an instance of this data model represents a row in the product table. Each product instance corresponds to a unique record in the table, encapsulating details such as name, description, price, stock quantity, and its associated image names in the form of a list.
- The ID of a product, stored as a Long data type.
- The name of a product, stored as a String data type.
- The description of a product, stored as a String data type.
- The stock quantity of a particular product, stored as an Integer data type.
- A list of Strings holding the associated images of that product.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | package com.demo.restproduct.model; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import lombok.AllArgsConstructor; import jakarta.persistence.*; import java.util.List; @Data @Entity @Builder @NoArgsConstructor @AllArgsConstructor public class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String description; private Double price; private Integer stockQuantity; @ElementCollection @CollectionTable(name = "product_images") private List<String> imagePaths; } |
4. Creating the Repository (DAO)
To handle DAO operations, we’ll create a repository using Spring Data JPA. This is straightforward, simply extend the JpaRepository
interface and specify the generic types that the repository will manage.
The first generic parameter represents the model entity (e.g., Product
) that the repository will handle, while the second parameter defines the type of the entity’s primary key (e.g., ID
).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | package com.demo.restproduct.repository; import com.demo.restproduct.model.Product; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @Repository public interface ProductRepository extends JpaRepository<Product, Long> { @Query( "SELECT p FROM Product p " + "WHERE LOWER(p.name) LIKE LOWER(CONCAT('%', :query, '%')) " + "OR LOWER(p.description) LIKE LOWER(CONCAT('%', :query, '%'))" ) List<Product> findProductsByQuery(@Param("query") String query); } |
We’ve also defined a custom query using the @Query
annotation. This query searches for matching terms in both the product’s title and description. If it match found, it then returns a list of the corresponding products.
5. Implementing the Product Service Layer
The product service layer handles product specific business logic. This layer uses the repository to interact with the database and includes additional logic for handling images and DTO conversions. It acts as intermediary between controller and repository.
This service class relies on the following:
ProductRequestDTO
Class: For transferring product data from the controller.ProductRepository
Interface: For database operations related to theProduct
entity.ImageService
Class: For saving and managing image files.
Responsibilities:
- Receiving a
ProductRequestDTO
object and a list of product images from the controller. - Calling the
saveImages
method fromImageService
to save the uploaded images and retrieve their unique names (or paths). - Converting the DTO to a
Product
entity using the builder pattern, including the saved image paths for association. - Saving the product along with its associated image paths in the database using
ProductRepository
. - Fetching a product by its ID, with optional handling for cases where the product doesn’t exist in the database.
- Retrieving all products from the database as a list for display or processing.
- Searching for products based on a query string using a custom repository method.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | package com.demo.restproduct.service; import com.demo.restproduct.dto.ProductRequestDTO; import com.demo.restproduct.model.Product; import com.demo.restproduct.repository.ProductRepository; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; import org.springframework.web.multipart.MultipartFile; import java.util.*; @Service @RequiredArgsConstructor public class ProductService { private final ProductRepository productRepository; private final ImageService imageService; public Product saveProduct(ProductRequestDTO productRequestDTO, List<MultipartFile> productImages) { Product product = Product.builder() .name(productRequestDTO.getName()) .description(productRequestDTO.getDescription()) .price(productRequestDTO.getPrice()) .stockQuantity(productRequestDTO.getStockQuantity()) .imagePaths(imageService.saveImages(productImages)) .build(); return productRepository.save(product); } public Optional<Product> getProductById(Long id) { return productRepository.findById(id); } public List<Product> getAllProducts() { return productRepository.findAll(); } public List<Product> getProductsByQuery(String query) { return productRepository.findProductsByQuery(query); } } |
6. Implementing the Image Service Layer
The image service layer is responsible for managing image related operations, including saving uploaded image files and retrieving them for use. In addition, we also use @Value
annotation to inject configurable properties such as the upload directory and allowed MIME types for image files from application.properties.
Responsibilities:
- Validating the uploaded files to ensure they are image files by checking their MIME types.
- Ensuring the number of uploaded files does not exceed the limit.
- Generating unique file names for each uploaded image using UUID.
- Saving the validated image files to the configured upload directory.
- Returning a list of saved image file names to associate them with product model.
- Retrieving a specific image file by its name from the upload directory.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 | package com.demo.restproduct.service; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.*; @Service public class ImageService { @Value("${file.upload-dir}") private String uploadDir; @Value("${file.allowed-types}") private String[] allowedMimeTypes; private boolean isImageFile(MultipartFile file) { String contentType = file.getContentType(); return contentType != null && Arrays.asList(allowedMimeTypes).contains(contentType); } private static String generateImageName(String originalFileName) { String extension = ""; int extensionIndex = originalFileName.lastIndexOf('.'); if (extensionIndex > 0) { extension = originalFileName.substring(extensionIndex); } return String.format("%s%s", UUID.randomUUID().toString().replace("-", ""), extension); } public List<String> saveImages(List<MultipartFile> files) { int max_files = 5; if (files.size() > max_files) { throw new IllegalArgumentException("Maximum of " + max_files + " files can be uploaded."); } List<String> savedFileNames = new ArrayList<>(); for (MultipartFile file : files) { if (!file.isEmpty() && isImageFile(file)) { String originalFileName = file.getOriginalFilename(); if (originalFileName == null) continue; String newFileName = generateImageName(originalFileName); Path filePath = Paths.get(uploadDir).resolve(newFileName); try { Files.copy(file.getInputStream(), filePath); savedFileNames.add(newFileName); } catch (IOException e) { System.out.println("Error saving file: " + e.getMessage()); } } } return savedFileNames; } public AbstractMap.SimpleEntry<Resource, String> getImageFile(String imageName) { try { Path filePath = Paths.get(uploadDir).resolve(imageName).normalize(); if (!Files.exists(filePath)) { return null; } Resource resource = new UrlResource(filePath.toUri()); String contentType = Files.probeContentType(filePath); if (contentType == null) { contentType = "application/octet-stream"; } return new AbstractMap.SimpleEntry<>(resource, contentType); } catch (Exception e) { System.out.println("Error loading file: " + e.getMessage()); return null; } } } |
7. App Setup
To ensure that the directory for uploading images exists, we use an ApplicationReadyEvent
listener. If the directory specified in the application properties (file.upload-dir
) does not exist, it will be created after the application is ready.
We handle this in the listener instead of the ImageService class to separate the concern of application setup from business logic, ensuring that the service focuses solely on processing the image files. If required we can add additional
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | package com.demo.restproduct.listener; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @Component public class AppSetup { @Value("${file.upload-dir}") private String UPLOAD_DIR; @EventListener(ApplicationReadyEvent.class) private void createUploadDirectory() { try { Path path = Paths.get(UPLOAD_DIR); if (!Files.exists(path)) { Files.createDirectories(path); } } catch (IOException e) { System.err.println("Failed to create directory: " + UPLOAD_DIR); } } } |
8. Product Request DTO
Next, lets create a Controller but before we do that, we’ll need to define a ProductRequestDTO
to handle and validate the incoming data for product creation. And for validation, we’ll use constraints such as @NotBlank
, @Size
, @NotNull
, @Positive
, and others.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | package com.demo.restproduct.dto; import lombok.Data; import jakarta.validation.constraints.*; @Data public class ProductRequestDTO { @NotBlank(message = "Product name cannot be blank") @Size(max = 100, message = "Product name cannot exceed 100 characters") private String name; @NotBlank(message = "Description cannot be blank") @Size(max = 500, message = "Description cannot exceed 500 characters") private String description; @NotNull(message = "Price is required") @Positive(message = "Price must be greater than 0") private Double price; @NotNull(message = "Stock quantity is required") @Min(value = 0, message = "Stock quantity cannot be negative") private Integer stockQuantity; } |
9. Building the Product Controller
This controller manages requests specific to product and connects them to the service layer.
Here’s what our controller will responsible for –
- Fetch all products
- Retrieve a specific product by its ID
- Search for products using a query
- Creating a new product
For creating a product, a createProduct
request handler is defined to handle both textual data and multimedia data. One of the most common ways to handle such mixed data is by using multipart/form-data
, which allows sending files and text fields in a single request, with each part separated by boundaries
So we’ll be defining two boundaries:
product
: This is the boundary that contains the textual information (product name, description, price, etc.). This boundary will map to theProductRequestDTO
object for further validation.productImages
: This boundary contains the uploaded files (product images). The files are handled as aList<MultipartFile>
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 | package com.demo.restproduct.controller; import com.demo.restproduct.dto.ProductRequestDTO; import com.demo.restproduct.model.Product; import com.demo.restproduct.service.ProductService; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import lombok.RequiredArgsConstructor; import java.util.List; @RestController @RequestMapping("/products") @RequiredArgsConstructor public class ProductController { private final ProductService productService; // Create a new product @PostMapping() public ResponseEntity<Product> createProduct( @RequestPart("product") @Valid ProductRequestDTO productRequest, @RequestPart("productImages") List<MultipartFile> productImages ) { Product product = productService.saveProduct(productRequest, productImages); return ResponseEntity.status(HttpStatus.CREATED).body(product); } // Get all products @GetMapping public ResponseEntity<List<Product>> getAllProducts() { List<Product> products = productService.getAllProducts(); return ResponseEntity.ok(products); } // Get a product by ID @GetMapping("/{id}") public ResponseEntity<Product> getProductById(@PathVariable Long id) { return productService.getProductById(id) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } // Search products by query @GetMapping("/search") public ResponseEntity<List<Product>> getProductsByQuery(@RequestParam String query) { List<Product> products = productService.getProductsByQuery(query); return ResponseEntity.ok(products); } } |
The different endpoints that will be available for product related operations are –
/api/products
(GET) – Retrieves the full list of products./api/products
(POST) – Create a new product./api/products/{id}
(GET) – Fetches a specific product by its ID./api/products/search?query=Samsung
(GET) – Searches for products matching the query term, such as ‘Samsung’
The controller processes these requests and fetches the appropriate product data from the service layer. from the service and returns it as JSON to the user.In the above controller we are using ResponseEntity to return the detailed information that includes the status code.
10. Building our Image Controller
This controller is responsible for serving requested images from the directory using the ImageService
class. The getImage request handler method call the getImageFile
method of the ImageService
class that returns the image as a Resource
instance along with its MIME type.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | package com.demo.restproduct.controller; import com.demo.restproduct.service.ImageService; import lombok.RequiredArgsConstructor; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.AbstractMap; @RequiredArgsConstructor @RequestMapping("/images") @RestController public class ImageController { private final ImageService imageService; @GetMapping("/{filename}") public ResponseEntity<Resource> getImage(@PathVariable String filename) { AbstractMap.SimpleEntry<Resource, String> imageFile = imageService.getImageFile(filename); return imageFile != null ? ResponseEntity.ok() .contentType(MediaType.parseMediaType(imageFile.getValue())) .body(imageFile.getKey()) : ResponseEntity.notFound().build(); } } |
11. Handling Exceptions
We also need to handle exceptions to cover various scenarios, such as when the user provides incorrect multipart data, misses a part in the form data, or faces errors due to DTO validation. Additionally, we need to handle cases where the number of uploaded images files exceeds the limit set in our application properties.
In Spring Boot, we handle these common exceptions in one place using the GlobalExceptionHandler
class. This class is marked with @RestControllerAdvice
, which tells Spring to use it for handling exceptions, making sure that any errors from controllers or thrown exception from service layer are caught and managed in a central location
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 | package com.demo.restproduct.exception; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.multipart.MultipartException; import org.springframework.web.multipart.support.MissingServletRequestPartException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import java.util.HashMap; import java.util.Map; @RestControllerAdvice public class GlobalExceptionHandler { // Handling validation errors for our DTO @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex) { Map<String, String> errors = new HashMap<>(); ex.getBindingResult().getAllErrors().forEach((error) -> { String fieldName = ((FieldError) error).getField(); String errorMessage = error.getDefaultMessage(); errors.put(fieldName, errorMessage); }); return new ResponseEntity<>(errors, HttpStatus.BAD_REQUEST); } // This exception is thrown when a required request part is missing in a multipart request @ExceptionHandler(MissingServletRequestPartException.class) public ResponseEntity<Map<String, String>> handleMissingServletRequestPartException(MissingServletRequestPartException ex) { Map<String, String> error = Map.of( "error", "Missing request part", "missingPart", ex.getRequestPartName() ); return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST); } // We are using this for handling MultipartException @ExceptionHandler(MultipartException.class) public ResponseEntity<String> handleMultipartException(MultipartException ex) { return ResponseEntity .status(HttpStatus.BAD_REQUEST) .body("Invalid multipart request: " + ex.getMessage()); } // We are using this for handling IllegalArgumentException (e.g. when number of files exceeds the limit) @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity<String> handleIllegalArgumentException(IllegalArgumentException ex) { return ResponseEntity .status(HttpStatus.PAYLOAD_TOO_LARGE) .body("Invalid argument: " + ex.getMessage()); } } |
12. Running the Application
Finally, start the application and lets try to upload few products using API testing tool like Postman.
Make a POST request on http://127.0.0.1:8080/api/products
with the necessary form-data, including the product details and image file. You’ll receive a JSON response similar to the example below.
If the required part (e.g., productImage
) is missing in the request, our exception handler for MissingServletRequestPartException
will respond with an appropriate error message.
Similarly, if the required textual data is incorrect or missing, our DTO validation will fail, resulting in a MethodArgumentNotValidException
. This exception contains details about the fields with errors.
Now, let’s try to retrieve the product and one of its associated images. To do this, first send a GET request to the appropriate endpoint (e.g., http://127.0.0.1:8080/api/products/{productId}
.
Next, from the previous response pick an image from the list and send a GET request to http://127.0.0.1:8080/api/images/{filename}
, where {filename}
is the image name you received from the product details response. This will return the actual image associated with the product
Leave a Reply