Error Handling In A RESTful Web Service

In my previous post, we created a RESTful web service for a hotel guest registration system. The application however, has no mechanism for handling situations where the client sends requests for hotel guests that no longer exist. If an HTTP GET operation is sent with an ID that is not in the database, HTTP will still return a status of 200, even if the response body is empty. We need to update the rest controller to return a 404 (NOT FOUND) in this case. We also want to add appropriate errors for trying to add a guest that already exists, or if an attempt to delete a guest is not successful because the guest is not found.

Add Bean Validation Annotations

First, let’s annotate our GuestDTO bean to give our fields some low level constraints. These constraints will set the maximum length and prevent empty fields. The lines in bold are the additions. See Figure 1.

package com.rayburn.house.dto;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Table;

import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.NotEmpty;

@Entity
@Table(name="Guests")
public class GuestDTO {
	
	@Id
	@GeneratedValue
	@Column(name = "GUEST_ID")
	private Long id;
		
	@NotEmpty
	@Length(max=50)
	@Column(name = "FIRST_NAME")
	private String firstName;
	
	@NotEmpty
	@Length(max=50)
	@Column(name = "LAST_NAME")
	private String lastName;
	
	@NotEmpty
	@Length(max=150)
	@Column(name = "ADDRESS")
	private String address;
	
	@NotEmpty
	@Length(max=150)
	@Column(name = "CITY")
	private String city;
	
	@NotEmpty
	@Length(max=50)
	@Column(name = "STATE")
	private String state;
	
	@NotEmpty
	@Length(max=10)
	@Column(name = "ZIP")
	private String zip;
	
	@NotEmpty
	@Length(max=80)
	@Column(name = "EMAIL")
	private String email;
	
	@Column(name = "BUNGALOW_NUM")
	private int bungalowNum;

        //Getters and Setters follow
Figure 1.

Without the above validation, the service will accept empty data and will return a status of 200 indicating that everything is ok. With the addition of the annotation, the service will return a HTTP status of 500, internal server error. The message included in the response will indicate a failed validation.

Add Custom Errors Using Bean Annotation

We can further customize error messages at the bean level. The validation API’s we used in the above example includes the ability to customize error messages for required fields and for fields being over their limit in length. First, let’s create a place to store our custom messages by creating a folder called “messages” inside the src/main/resources folder. Inside the new messages folder, create a file called messages.properties, to which we will add the text below.

error.firstName.empty=The first name is a required field.
error.lastName.empty=The last name is a required field.
error.lastName.length=The last name is limited to 50 characters.
error.address.empty=The address is a required field.
error.address.length=The address is limited to 150 characters.
error.city.empty=The city is a required field.
error.city.length=The city is limited to 150 characters.
error.state.empty=The state is a required field
error.state.length=The state is limited to 50 characters.
error.zip.empty=The zip code is a required field.
error.zip.length=The zip code is limited to 10 characters.
error.email.empty=The email address is a required field.
error.email.length=The email address is limited to 80 characters.
Figure 2.

Now we need to update our GuestDTO by appending our new messages to the validation annotations as shown below in Figure 3.  Do this for each of the error messges from figure 2.

@NotEmpty(message = "error.lastName.empty")
@Length(max=50, message = "error.lastName.length")
@Column(name = "LAST_NAME")
private String lastName;
Figure 3.

Next, we need to build some classes to package these errors up into memory for our use. The first one will be a bean to hold our errors. Start by creating a new package in your app called com.rayburn.house.exception. Inside that package, create the following:

package com.rayburn.house.exception;

import java.awt.TrayIcon.MessageType;

public class FieldValidationError {
	
	private String field;
	private String message;
	private MessageType type;
	public String getField() {
		return field;
	}
	//Setters and Getters go here.
}

Figure 4.

Next, in this same package, we need to create a class that will generate error details for our response. Create the below FieldErrorDetails class. As you can see by the use of a List object, the response will be capable of handling more than one error at a time.

package com.rayburn.house.exception;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class FieldErrorDetails {

	private String errorName;
	private int errorStatus;
	private String errorDetail;
	private long errorTimestamp;
	private String errorPath;
	private String errorMessage;
	private Map<String, List> errors = 
			new HashMap<String, List>();

        //Setters and Getters go here.
}
Figure 5.

In order to be able to retrieve our new error messages from the properties file that we created earlier, we must create a bean that will be available to inject when we need to use it. Create the configuration class below. The @Configuration annotation tells Spring that this class will contain one or more bean definitions.

package com.rayburn.house;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;

@Configuration
public class GuestRegistrationConfiguration {
	
	@Bean(name = "messageSource") 
	public ReloadableResourceBundleMessageSource messageSource() {
		
		ReloadableResourceBundleMessageSource messageBundle = 
				new ReloadableResourceBundleMessageSource();
		messageBundle.setBasename("classpath:messages/messages");
		messageBundle.setDefaultEncoding("UTF-8");
		return messageBundle;
	}
}
Figure 6.

Now let’s create a handler class to handle errors coming back from the controller class. Using this we can make our errors more meaningful, and allow us to send our custom errors from the properties file in the response back to the client. Inside the exception package, create a class called ValidationHandler in the com.rayburn.house.exception package as seen below. This ValidationHandler can be configured to handle any exception, but we will focus on the MethodArgumentNotValidException. When this exception is thrown, the handleError method will package up the details into the objects defined in the classes we created in figures 4 and 5, and will place those objects into a ResponseEntity for delivery to the client.

package com.rayburn.house.exception;

import java.awt.TrayIcon.MessageType;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

import javax.servlet.http.HttpServletRequest;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;

@ControllerAdvice
public class ValidationHandler {
	
	private MessageSource messageSource;
	
	@Autowired ValidationHandler(MessageSource messageSource) {
		this.messageSource = messageSource;
	}
	
	@ExceptionHandler(MethodArgumentNotValidException.class)
	@ResponseStatus(HttpStatus.BAD_REQUEST)
	public ResponseEntity<FieldErrorDetails> handleError(
			MethodArgumentNotValidException methodNotValidException,
			HttpServletRequest request) {
		
		FieldErrorDetails  fieldErrorDetails = new FieldErrorDetails();
		fieldErrorDetails.setErrorDetail("Field Validation Failed");
		fieldErrorDetails.setErrorMessage(methodNotValidException.getClass().getName());
		fieldErrorDetails.setErrorName("Field Validation Error");
		fieldErrorDetails.setErrorPath(request.getRequestURI());
		fieldErrorDetails.setErrorStatus(HttpStatus.BAD_REQUEST.value());
		
		BindingResult  result = methodNotValidException.getBindingResult();
		List<FieldError> fieldErrors = result.getFieldErrors();
		
		for (FieldError error : fieldErrors) {
			FieldValidationError fieldError = manageFieldError(error);
			List fieldValidationErrorsList = 
					fieldErrorDetails.getErrors().get(error.getField());
			if (fieldValidationErrorsList == null) {
				fieldValidationErrorsList = new ArrayList();
			}
			fieldValidationErrorsList.add(fieldError);
			fieldErrorDetails.getErrors().put(error.getField(), fieldValidationErrorsList);
		}
		
		return new ResponseEntity<FieldErrorDetails> (fieldErrorDetails, HttpStatus.BAD_REQUEST);
		
	}
	
	private FieldValidationError manageFieldError(final FieldError error) {
		FieldValidationError fieldValidationError = new FieldValidationError();
		
		if(error!=null) {
			Locale currentLocale = LocaleContextHolder.getLocale();
			String msg = messageSource.getMessage(error.getDefaultMessage(), null, currentLocale);
			fieldValidationError.setField(error.getField());
			fieldValidationError.setType(MessageType.ERROR);
			fieldValidationError.setMessage(msg);		
		}
		return fieldValidationError;
		
	}
}
Figure 7.

Lastly, we must put the @Valid annotation on the GuestDTO parameter in the addNewGuest method. This tells java that we want to turn on the validations we have placed in the GuestDTO class. See below.

@PostMapping(value = "/", consumes = MediaType.APPLICATION_JSON_VALUE)
	public ResponseEntity<GuestDTO> addNewGuest(@Valid @RequestBody final GuestDTO guest){
            ...
	}
Figure 8.

After restarting your web application, test your validation by breaking one of your new validation rules.  For instance, the below response is created by omitting the last name:

STS Import Wizard Settings
Figure 9.

 

Add Custom Validation for Duplicates

Besides adding validation on the length of the fields, our hotel owners also want to make sure that duplicate entries for a guest do not get added to the system. They also don’t want their bungalows double booked.  To add this kind of validation, we must first go add additional queries to our Spring Data JPA in our GuestJpaRepository. Learn more about how to define Spring Data JPA query methods here. We will want to find guests by the their first and last name, and we will want to find bungalows by their bungalow number.  See figure 10.

package com.rayburn.house.repository;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import com.rayburn.house.dto.GuestDTO;

@Repository
public interface GuestJpaRepository extends JpaRepository<GuestDTO, Long> {
	
	GuestDTO findByLastName(String lastName);
	
	List<GuestDTO> findByFirstNameAndLastName(String firstName, String lastName);
	
	GuestDTO findByBungalowNum(int bungalowNum);
}
Figure 10.

Next we will want to update our addNewGuest method in GuestRegistrationRestController to not allow duplicate entries for our guests and to not overbook a bungalow. See Figure 11.

 @PostMapping(value = "/", consumes = MediaType.APPLICATION_JSON_VALUE)
  public ResponseEntity<GuestDTO> addNewGuest(@RequestBody final GuestDTO guest){
			
	//Make sure this guest isn't already in the system.
	if (!(guestJpaRepository.findByFirstNameAndLastName(
		guest.getFirstName(), guest.getLastName()).isEmpty())) {
		  return new ResponseEntity<GuestDTO> (guest, HttpStatus.CONFLICT);
	}
		
	//Make sure no guests are already in this bungalow.
	if (guestJpaRepository.findByBungalowNum(guest.getBungalowNum()) != null)
		return new ResponseEntity<GuestDTO> (guest, HttpStatus.CONFLICT);
				
	guestJpaRepository.save(guest);
	return new ResponseEntity<GuestDTO>(guest, HttpStatus.CREATED);
}
Figure 11.

In the example in figure 3 above, we check the data repository for both conditions and set the response with a HTTP status of 409, indicating that there was a conflict. The database will not get updated with the new data in these conditions.

Add Custom Error Messages

Because we are forward thinking software developers, we know that our clients, the Rayburn’s, are going to want more descriptive error messages returned from the service. Before we add more checks for error conditions in our REST controller, let’s create a class to define an error for our system that we will use to send custom error messages back to the client in the ResponseEntity.  Create a new package in your Spring Boot app called com.rayburn.house.exception. Create the CustomError class as shown below in Figure 12. Because we are now using GuestDTO as a superclass, you must create a getErrorMessage() method in super type GuestDTO.

package com.rayburn.house.exception;

import com.rayburn.house.dto.GuestDTO;

public class CustomError extends GuestDTO {

	private String message;
	
	public CustomError(final String message) {
		this.message = message;
	}
	
	@Override
	public String getErrorMessage() {
		return message;
	}
}
Figure 12.

Now let’s go update the GuestRegistrationRestController methods with more error checking and custom error messages. The custom error messages will be returned from the server where the guest object was previously returned.

package com.rayburn.house.rest;

import java.util.List;
import java.util.Optional;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;


import com.rayburn.house.dto.GuestDTO;
import com.rayburn.house.exception.CustomError;
import com.rayburn.house.repository.GuestJpaRepository;

@RestController
@RequestMapping("/api/guest")
public class GuestRegistrationRestController {
	
	private GuestJpaRepository guestJpaRepository;
	
	@Autowired
	public void setGuestJpaRepository(GuestJpaRepository guestJpaRepository) {
		this.guestJpaRepository = guestJpaRepository;
	}
	
	@PostMapping(value = "/", consumes = MediaType.APPLICATION_JSON_VALUE)
	public ResponseEntity<GuestDTO> addNewGuest(@RequestBody final GuestDTO guest){
		//Make sure this guest isn't already in the system.
		if (!(guestJpaRepository.findByFirstNameAndLastName(
		  guest.getFirstName(), guest.getLastName()).isEmpty())) {
		    return new ResponseEntity<GuestDTO> (
			new CustomError("Unable to add the new guest. A guest with the name "
		          + guest.getFirstName() + " " + guest.getLastName() 
			  + " already exists."), HttpStatus.CONFLICT);
		}
		
		//Make sure no guests are already in this bungalow.
		if (guestJpaRepository.findByBungalowNum(guest.getBungalowNum()) != null)
		  return new ResponseEntity<GuestDTO> (
			new CustomError("Unable to add the new guest. A guest is already "
			  + "assigned to bungalow " + guest.getBungalowNum()),
			  HttpStatus.CONFLICT);
				
		guestJpaRepository.save(guest);
		return new ResponseEntity<GuestDTO>(guest, HttpStatus.CREATED);
	}
	
	
	@GetMapping("/{id}")
	public ResponseEntity<GuestDTO> getGuestById(@PathVariable("id") final long id) {
		Optional<GuestDTO> guest = guestJpaRepository.findById(id);
		if (!(guest.isPresent())) {
			return new ResponseEntity<GuestDTO> (
				new CustomError("Guest with id " + id + " not found"),
					HttpStatus.NOT_FOUND);
		}
		return new ResponseEntity<GuestDTO>(guest.get(),HttpStatus.OK);
	}
	
	
	@PutMapping(value = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE)
	public ResponseEntity<GuestDTO> updateGuest(@PathVariable("id") final Long id, @RequestBody GuestDTO guest) {
		Optional<GuestDTO> currentGuest = guestJpaRepository.findById(id);
		if (!(currentGuest.isPresent())) {
			return new ResponseEntity<GuestDTO> (
				new CustomError("Unable to update. Guest with id " 
				   + id + " not found"),HttpStatus.NOT_FOUND);
		}
		
		currentGuest.get().setFirstName(guest.getFirstName());
		currentGuest.get().setLastName(guest.getLastName());
		currentGuest.get().setAddress(guest.getAddress());
		currentGuest.get().setCity(guest.getCity());
		currentGuest.get().setState(guest.getState());
		currentGuest.get().setZip(guest.getZip());
		currentGuest.get().setEmail(guest.getEmail());
		currentGuest.get().setBungalowNum(guest.getBungalowNum());
		
		guestJpaRepository.saveAndFlush(currentGuest.get());
		
		return new ResponseEntity<GuestDTO>(currentGuest.get(), HttpStatus.OK);
		
		
	}
	
	@GetMapping("/")
	public ResponseEntity<List<GuestDTO>> listAllGuests() {
		List  guests = guestJpaRepository.findAll();
		if (guests.isEmpty()) 
			return new ResponseEntity<List<GuestDTO>> (HttpStatus.NO_CONTENT);

		return new ResponseEntity<List<GuestDTO>>(guests, HttpStatus.OK);
	}
	
	@DeleteMapping("/{id}")
	public ResponseEntity<GuestDTO> deleteGuest(@PathVariable("id") final Long id) {
		Optional currentGuest = guestJpaRepository.findById(id);
		if(!(currentGuest.isPresent())) {
			return new ResponseEntity<GuestDTO> (
				new CustomError("Unable to delete. Guest with id " + id + "not found"),
				HttpStatus.NOT_FOUND);
			
		}
		guestJpaRepository.delete(currentGuest.get());
		return new ResponseEntity<GuestDTO>(HttpStatus.NO_CONTENT);
		
	}
}

Figure 12.

After restarting your application, the response body should contain a null errorMessage field when a POST is successful (status 201). Make an attempt to add a new guest to a bungalow that is already booked and an attempt to add a new quest with the same name as one already in the database. You should see your corresponding error messages in the response body. Also, attempt to do GET and DELETE requests on an id that does not exist in the database.

 

Leave a Reply

Your email address will not be published. Required fields are marked *