Blogg
Här finns tekniska artiklar, presentationer och nyheter om arkitektur och systemutveckling. Håll dig uppdaterad, följ oss på LinkedIn
Här finns tekniska artiklar, presentationer och nyheter om arkitektur och systemutveckling. Håll dig uppdaterad, följ oss på LinkedIn
I have previously shared some thoughts about when to use REST and when not to in and the REST is history. In that post, I stated that a good time to use REST is when “you need public APIs that are easy to understand and use”.
Working with public APIs is fun and rewarding—if you design your APIs the right way.
If you don’t, your users will be angry and complain.
In this post, I have gathered some of the more general best practices for building a good REST API. I coach (force?) developers I work with to follow them and use this as a checklist when I do code reviews on REST API code.
To make things easy for myself, I am using Spring Boot and their way of coding and exposing REST APIs. There are many other frameworks out there, and most of them reward the same behaviour.
Spring Framework exposes all the official IANA HTTP Status Codes in the class org.springframework.http.HttpStatus. If you need to create your own custom status code, it is easy to implement it using the interface HttpStatusCode.
This rule can be broken down into two different parts:
In the first case, this comes down to the perception that as long as the caller has done nothing wrong, you should say OK.
Let us look at an example:
@GetMapping("/routes/{busId}")
public RouteDTO getRouteById(@PathVariable Long id) {
Route route = routeService.findById(id);
if(route == null) {
return null; // or return routeMapper.emptyDTO();
} else {
return routeMapper.toDTO(route);
}
}
By default, the Spring RestController
will map a null
return value to an empty response body and use 200
as the status code.
In both cases, the client has to examine the body for content to figure out if you returned nothing because it was not found, or if this is the actual response to the request.
Spring has thought about this and provides the ResponseEntity<T>
class to the rescue.
@GetMapping("/routes/{routeId}")
public ResponseEntity<RouteDTO> getRouteById(@PathVariable Long id) {
Optional<Route> route = routeService.findById(id);
return route.map(routeMapper::toDTO)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
By using Optional<Route>
, we avoid returning null
and also open up to a compact, efficient, and easy-to-read code without a lot of if-statements.
route.map(routeMapper::toDTO)
is the same as the null check. If the Optional
has a value, use that value (map it) to routeMapper.toDTO(route)
.
.map(ResponseEntity::ok)
uses the ResponseEntity.ok(T body)
method to set the status to OK
and wrap the provided DTO from the last mapping.
.orElse(ResponseEntity.notFound().build())
handles setting the status to 404 and builds the correct body.
This fluent coding style has grown on me over the years, since it reads like a normal sentence and eliminates a lot of if-else statements. This would be the “regular code”:
@GetMapping("/routes/{routeId}")
public ResponseEntity<RouteDTO> getRouteById(@PathVariable Long id) {
Optional<Route> routeOptional = routeService.findById(id);
if (routeOptional.isPresent()) {
Route route = routeOptional.get();
RouteDTO routeDTO = routeMapper.toDTO(route);
return ResponseEntity.ok(routeDTO);
} else {
return ResponseEntity.notFound().build();
}
}
When asking for something with an id
like GET /routes/{id}
, there are not that many different values that can be sent in.
But what if we want to create a resource, like a new bus?
Let’s do it horribly…
@PostMapping("/buses")
public String createBus(@RequestBody Map<String, Object> busData){
if(busData == null) {
return "Error: Request body is empty!"; // We covered this...
}
try {
String name = (String) busData.get("name");
Integer capacity = (Integer) busData.get("capacity");
if(name == null || name.isEmpty() || capacity == null || capacity <= 0 ) {
return "Error: Invalid data"; // But WHAT data??
}
Bus bus = new Bus();
bus.setName(name);
bus.setMaxSeats(capacity);
busRepository.save(bus); // Hello Bobby Tables!
return "Created a bus with name: " + name;
} catch (Exception e) {
return "Error: Something went wrong.";
}
}
We already covered the proper HTTP Status codes, so let us not go through that again.
The first thing we notice is that we can send in absolutely anything. This opens up for out-of-memory attacks. What if someone sends in a huge map?
A common mistake I see is that the Bus
entity stored in the Repository is used as a DTO. In our model, the Bus looks like this (not showing the getters/setters):
@Entity
@Table(name= "buses")
public class Bus {
@Id
@Column(name = "bus_id")
private Long id;
@Column(name = "name")
private String name;
@Column(name = "max_seats")
private Integer maxSeats;
@Column(name = "is_active")
private Boolean isActive;
}
To use this in the REST API is a bad idea, since this entity is used to persist our data in the database and should not be exposed publicly. We would also need to have an id
when we create the bus, so when you find the model entities exposed like this, look for the GET /buses/nextId
endpoint.
So to create a bus, we need the CreateBusRequestDTO
, using Project Lombok to make it more compact:
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CreateBusRequestDTO {
@JsonProperty("name")
private String name;
@JsonProperty("capacity")
private Integer capacity;
}
and the CreateBusResponseDTO
:
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CreateBusResponseDTO {
@JsonProperty("bus_id")
private Long id;
@JsonProperty("name")
private String name;
@JsonProperty("capacity")
private Integer capacity;
@JsonProperty("is_active")
private Boolean isActive;
}
and now we can add methods in the BusMapper to do the toEntity
and toDTO
.
But do we get the correct and reasonable values?
We have some fields in this request that open the attack surface. One is the name
. In our database, we have it limited to 256 characters, so we should not accept any names longer than this. But we also don’t accept any names shorter than 6 characters.
The Jakarta Bean Validation framework to the rescue!!
import jakarta.validation.constraints.*;
import com.fasterxml.jackson.annotation.JsonProperty;
public class CreateBusRequestDTO {
@NotBlank(message = "Name cannot be null or empty.")
@Size(min = 6, max = 256, message = "Name must be between 6 and 256 characters long.")
@JsonProperty("name")
private String name;
@NotNull(message = "Capacity cannot be null.")
@Min(value = 1, message = "Capacity must be a positive number.")
@Max(value = 150, message = "We don't use bi-articulated buses. Max 150 seats.")
@JsonProperty("capacity")
private Integer capacity;
// Getters and Setters
}
Our BusController
now becomes a lot denser. All the mapping logic back and forth from the DTOs is now managed by the BusService
and BusMapper
classes.
@PostMapping("/buses")
public ResponseEntity<CreateBusResponseDTO> createBus(@Valid @RequestBody CreateBusRequestDTO busRequestDTO) {
CreateBusResponseDTO createdBusResponse = busService.createBus(busRequestDTO);
return new ResponseEntity<>(createdBusResponse, HttpStatus.CREATED);
}
Most of the status codes are managed by the framework now. But not all. What if we can’t connect to the SQL server or something else?
I have seen so many REST API implementations where the error handling code is copy-pasted try/catch statements with inconsistent response codes, no logging, and even some where the exceptions are exposed in the response. We never ever return an Exception in a response.
When implementing a Global Exception Handler, you have two options: ControllerAdvice
and RestControllerAdvice
. They function the same way, but the latter will automatically serialize the ResponseEntity
into the response body without us having to do anything.
Gemini created this GlobalExceptionHandler for me:
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.NoHandlerFoundException;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusNotFoundException.class)
public ResponseEntity<String> handleBusNotFoundException(BusNotFoundException ex) {
return new ResponseEntity<>(ex.getMessage(), HttpStatus.NOT_FOUND);
}
@ExceptionHandler(NoHandlerFoundException.class)
public ResponseEntity<String> handleNoHandlerFoundException(NoHandlerFoundException ex) {
// Log this request to detect exploits.
// String requestedUri = request.getRequestURI();
// logger.warn("NoH: {}", requestedUri);
return new ResponseEntity<>("The requested resource was not found. Look in the documentation if it is implemented.", HttpStatus.NOT_FOUND);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleGeneralException(Exception ex) {
// Log the exception for debugging purposes
// logger.error("An unexpected error occurred: {}", ex.getMessage(), ex);
return new ResponseEntity<>("An internal server error occurred.", HttpStatus.INTERNAL_SERVER_ERROR);
}
}
The first one is quite intuitive. The BusService
will throw a BusNotFoundException
if there is no bus with that ID.
The @ExceptionHandler(NoHandlerFoundException.class)
is a bit special. In Spring MVC, this is thrown when there is no controller that can handle a request. We can now control how we respond and give something more intuitive than the standard 500
. And if needed, log request attempts to find any suspicious usage.
The @ExceptionHandler(Exception.class)
takes care of all the rest.
And since this is Java, we could create a base class for our different types of exceptions if we want to.
Fetching all the buses and displaying them in a dropdown is fine when you have 20-50 buses. But assume that you are doing this for Stockholms Lokaltrafik, SL. They operate around 500 bus lines, using about 2,300 buses, and on average one route starts every 10 minutes. That is 6 routes per hour per line on average, or 70,000+ routes.
This type of code is not that uncommon:
@GetMapping("/routes")
public ResponseEntity<List<RoutesResponseDTO>> getAllBuses() {
// Should go through a mapper.
List<RoutesResponseDTO> routes = routeService.findAll();
return ResponseEntity.ok(routes);
}
But when we look into the Route
entity we find this line (assuming one Route, one Bus):
@ManyToOne(fetch = FetchType.LAZY)
private Bus bus;
Ok, why do I have to care? This is why we use JPA.
When serializing Routes to JSON, the Spring framework will fetch each Bus in a lazy way, for EACH Route. This will trigger N separate calls to the database.
There are two different ways that can be used to solve this. The first one is quite simple:
Don’t return the full Bus entity.
I would map the List<Route>
to RoutesResponseDTO
like this:
public class RoutesResponseDTO {
private Long id;
private String name;
private Long busId; // Only the ID of the bus
}
and I can now do a projected request to the database:
@Repository
public interface RouteRepository extends JpaRepository<Route, Long> {
@Query("SELECT r.id, r.name, r.bus.id FROM Route r")
List<Object[]> findAllRouteDetails();
}
So we are using the @Query
and projecting our result to this special DTO with specific fields and making the query a lot smaller.
But what if I REALLY need to show all the routes and all the buses?
We now need to change our RoutesResponseDTO to use a nested BusDTO (since we don’t expose entities).
public class RoutesResponseDTO {
private Long id;
private String name;
private BusDTO bus; // Nested DTO for bus details
}
...
public class BusDTO {
private Long id;
private String name;
private Integer capacity;
private Boolean isActive;
}
The RouteRepository can now use a JOIN FETCH
query to do this effectively, and then you use the RouteMapper to do the heavy lifting.
@Repository
public interface RouteRepository extends JpaRepository<Route, Long> {
@Query("SELECT r FROM Route r JOIN FETCH r.bus")
List<Route> findAllWithBuses();
}
But it is still 70,000 routes to return.
I think that the country selection dropdown that is used when ordering things online is way too long.
In Spring Boot, there is the @Pageable
annotation and the JpaRepository
does implement findAll(Pageable pageable)
for you already. All the REST frameworks I have worked with have something similar. If they don’t have it, select another one.
In our controller, it would look like this:
@GetMapping("/routes")
public ResponseEntity<Page<RoutesResponseDTO>> getAllRoutes(Pageable pageable) {
Page<RoutesResponseDTO> routesPage = routeService.findAll(pageable);
return ResponseEntity.ok(routesPage);
}
And you would then do a similar request in the persistence layer.
Paging limits the amount of entities you return from your service. The Jakarta Validation has a class Size
and I try to always state an upper limit for how large a collection can be when sent in through the REST API. It is easy to have that limit as a parameter if you need to increase it (at a higher cost).
When you have made your REST API public and your customers have started using it, you will eventually get a call from support or from a Key Account Manager where they state that The Most Important Customer has performance issues.
This is why I always log all incoming requests. In Spring, there is this concept of Interceptors, so you can create a RequestLoggerInterceptor
. I also like to measure the roundtrip time for the request. Spring has the Micrometer framework built in, but you can use any metrics framework you want.
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
@Component
public class RequestLoggerInterceptor implements HandlerInterceptor {
private final MeterRegistry meterRegistry;
public RequestLoggerInterceptor(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// Store the timer object in the request attributes
Timer.Sample timerSample = Timer.start(meterRegistry);
request.setAttribute("timerSample", timerSample);
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
// Not used for timing, timing is completed in afterCompletion
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// Retrieve the timer object from request attributes
Timer.Sample timerSample = (Timer.Sample) request.getAttribute("timerSample");
if (timerSample != null) {
// Record the duration with Micrometer
timerSample.stop(meterRegistry.timer("http.server.requests",
"uri", request.getRequestURI(),
"method", request.getMethod(),
"status", String.valueOf(response.getStatus())
));
}
}
}
This will give you a good insight into what is happening. Micrometer also has a Tracer class that is nice if you have multiple services or just want to be able to trace your way through the code.
Luckily for us who like Spring Boot, there is already an out-of-the-box implementation of this in the Spring Actuators that gives you all the metrics that you need. Just make sure that you hide the actuator endpoints from the world.
This last one does not really have anything to do with how you should create good REST APIs, but it is important enough to repeat over and over again.
In the same way that we validate all user-provided data, use prepared statements to protect our databases, and turn on all the XSS features that are available in Spring, we should also protect the user when returning data to them.
We don’t really know how and when they will use your data, so I always use OWASP Java Encoder or Jsoup in my Service layer to sanitize the output.
String userInput = "<script>alert('xss');</script>";
String safeOutput = org.owasp.encoder.Encode.forHtml(userInput);
// safeOutput will be "<script>alert('xss');</script>"
There are many more good habits that you should use when you write a REST API but this covers about 70% of the major ones. We still need to look into good stuff like discoverable APIS using HAL and HATEOAS, versioning strategies, securing your APIs with JWT and throttling patterns. But this habits will make it less likely that you get screamed at by your customers.
This post covers common mistakes and best practices when building REST APIs, especially with Spring Boot.
Key takeaways:
@RestControllerAdvice
exception handler.Following these practices leads to safer, more maintainable, and user-friendly APIs.