How to make an API using Spring Data REST
What is Spring Data REST?
Spring Data REST is a module of the Spring framework that automatically exposes RESTful APIs for your Spring Data repositories. It simplifies the process of building data-driven services by converting your database entities into HTTP resources, handling common operations like CRUD, pagination, and sorting without the need to write boilerplate code. By using conventions, it automatically maps your repository methods to HTTP endpoints, allowing you to focus on your application’s logic while leveraging powerful features like HATEOAS links and data projections.
The above excerpt was generated by ChatGPT
The Project
We’re going to build an API for managing animal adoption to show SDR (Spring Data REST) capabilities. All CRUD endpoints are going to be generated by SDR and we’ll make a custom endpoint for updating the status. Everything is going to be mapped to the same group in Swagger.
All files can be found on my GitHub
Stack used in the project
We are going to use the following technologies for building our API:
- Java 21
- Spring Boot 3.3.4
- Spring Data REST
- PostgreSQL
- Docker
API Requirements
- An endpoint for creating a new animal, that receives:
- Name
- Description
- Image URL
- Category
- Birth Date
- Status (AVAILABLE, ADOPTED)
- An endpoint for getting animals, that returns:
- ID
- Name
- Description
- Image URL
- Category
- Birth Date
- Age <- Notice the extra field, we’re coming back later
- Status (AVAILABLE, ADOPTED)
An endpoint to update an animal status
- Swagger documentation for all endpoints
Project Structure
Our architecture will be split into domain and application, where:
- Domain: layer that handles external communication (Our controllers, forms, projections)
- Application: the business logic layer (Our models, services, repositories)
And we will have a Config package because normally, SDR expects our projection package to be a sub-package of models. But we’re making it a sub-package of domain, so we need to manually inform its configuration.
If we had more databases we could split into a third layer infrastructure that would manage them, but there’s no need for over-engineering.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
.
└── src/main/
├── java/com/example/adoption/
│ ├── application/
│ │ ├── controller/
│ │ │ └── AnimalController
│ │ ├── form/
│ │ │ └── UpdateAnimalStatusForm
│ │ └── projection/
│ │ └── AnimalOut
│ ├── domain/
│ │ ├── model/
│ │ │ └── Animal
│ │ ├── repository/
│ │ │ └── AnimalRepository
│ │ └── service/
│ │ └── AnimalService
│ ├── config/
│ │ └── RestConfiguration
│ └── AdoptionApplication
└── resources/
└── application-dev.yml
Configuration
Active Profile
Make sure to add “dev” as an active profile in your IDE configuration
Gradle
Add the following dependencies to your build.gradle
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-rest'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'
implementation 'org.springdoc:springdoc-openapi-data-rest:1.8.0'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'org.postgresql:postgresql'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testRuntimeOnly 'com.h2database:h2'
}
Application Properties
The application properties for our project:
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
spring:
application:
name: adoption
devtools:
restart:
enabled: true
exclude: static/**,public/**
livereload:
enabled: true
datasource:
url: jdbc:postgresql://localhost:5432/adoption
username: postgres
password: root
jpa:
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
hibernate:
ddl-auto: update
data:
rest:
basePath: /api
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui.html
Docker
Heres our Dockerfile, compose.yml and .dockerignore
1
2
3
4
5
6
7
8
9
10
11
#Build stage
FROM gradle:8.10.2-jdk21 AS build
WORKDIR /app
COPY . .
RUN gradle build
# Package stage
FROM openjdk:21-jdk-slim
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
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
services:
postgres:
image: 'postgres:latest'
container_name: postgres
ports:
- '5432:5432'
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: root
POSTGRES_DB: adoption
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
interval: 5s
timeout: 5s
retries: 5
app:
build:
context: .
dockerfile: Dockerfile
depends_on:
postgres:
condition: service_healthy
ports:
- '8080:8080'
environment:
SPRING_PROFILES_ACTIVE: docker
1
2
3
4
5
.gradle
.idea
build
out
src/test
And create a new application-docker.yml, changing the spring.datasource.url from “jdbc:postgresql://localhost:5432/adoption” to “jdbc:postgresql://postgres:5432/adoption”
Entity
Explaining some annotations:
@EntityListeners(AuditingEntityListener.class) -> This gives us the metadata of when an object was created or updated.
@JsonProperty(access = JsonProperty.Access.READ_ONLY) -> This removes the field from the POST endpoint that SDR creates.
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
@EntityListeners(AuditingEntityListener.class)
@Entity
@Table(name = "animals")
@Data
public class Animal {
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
@Column(name = "animal_id")
private Long id;
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
@CreatedDate
@Column(name = "created_date")
private Date createdDate;
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
@LastModifiedDate
@Column(name = "last_modified_date")
private Date lastModifiedDate;
@Column(name = "name", length = 100, nullable = false)
private String name;
@Column(name = "description", length = 500)
private String description;
@Column(name = "image_url", length = 255)
private String imageURL;
@Column(name = "category", length = 100, nullable = false)
private String category;
@Column(name = "birth_date", nullable = false)
private LocalDate birthDate;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private Status status;
public enum Status {DISPONIVEL, ADOTADO}
}
CRUD Endpoints
SDR automatically reads which interfaces our repository extends and configures the endpoint accordingly.
Since we’re using the JpaRepository, it already extends the PagingAndSortingRepository and CrudRepository, making our API more complete.
The Tag annotation is to group our controller under the same name for swagger, for when we create our custom endpoint to changing the status.
1
2
3
@RepositoryRestResource(collectionResourceRel = "animals", path = "animals")
@Tag(name = "Animal Controller", description = "Animal Adoption Endpoints")
public interface AnimalRepository extends JpaRepository<Animal, Long> {}
Status Endpoint
Now that all our CRUD endpoints are correctly configured, we’re going to make our PATCH endpoint to change the animal status. It will receive a JSON form including only the new status value.
Controller
Notice the Tag annotation again, this ensures that all endpoints are packed inside the same group in Swagger (ours and the SDR ones)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@AllArgsConstructor
@RepositoryRestController("/animals")
@Tag(name = "Animal Controller", description = "Animal Adoption Endpoints")
public class AnimalController {
private AnimalService animalService;
@PatchMapping(path= "/{id}/status", consumes = "application/json", produces = "application/json")
public ResponseEntity<Animal> updateStatus(
@PathVariable(value = "id") Long id,
@RequestBody UpdateAnimalStatusForm statusForm) {
Animal.Status status = statusForm.status();
try {
Animal updatedAnimal = animalService.updateStatus(id, status);
return ResponseEntity.ok(updatedAnimal);
} catch (EntityNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
}
}
Form
For best practices, we will create a specific form for this endpoint:
1
2
3
4
public record UpdateAnimalStatusForm(
Animal.Status status
) {
}
Service
And the service for updating the status:
1
2
3
4
5
6
7
8
9
10
11
12
@Service
@AllArgsConstructor
public class AnimalService {
private final AnimalRepository animalRepository;
public Animal updateStatus(Long id, Animal.Status status) {
Animal animal = animalRepository.findById(id).orElseThrow(EntityNotFoundException::new);
animal.setStatus(status);
return animalRepository.save(animal);
};
}
Projection
We need a DTO for GET requests that have the animal age and its ID (SDR hides the ID by default). First we create our custom projection:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Projection(name = "animalOut", types = Animal.class)
public interface AnimalOut {
Long getId();
String getName();
String getDescription();
String getImageURL();
String getCategory();
LocalDate getBirthDate();
default int getAge() {
return Period.between(getBirthDate(), LocalDate.now()).getYears();
}
Animal.Status getStatus();
}
Then we add it to our RestConfiguration:
1
2
3
4
5
6
7
8
9
@Configuration
public class RestConfiguration implements RepositoryRestConfigurer {
@Override
public void configureRepositoryRestConfiguration(RepositoryRestConfiguration config, CorsRegistry cors) {
config.getProjectionConfiguration()
.addProjection(AnimalOut.class);
}
}
Swagger
If you enter http://localhost:8080/swagger-ui/index.html#/ in your browser, you’ll be able to see the Swagger generated, with our CRUD and status endpoints grouped together:
Input Validation
Status - Wrong Enum
If you run a status update with a wrong value for the enum, Spring will respond with a HttpMessageNotReadableException exposing API information that we shouldn’t expose. We can make a CustomRestExceptionHandler for our simple API to handle this:
1
2
3
4
5
6
7
8
9
10
11
12
13
@ControllerAdvice
public class CustomRestExceptionHandler {
@ExceptionHandler(HttpMessageNotReadableException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ResponseEntity<String> handleHttpMessageNotReadableException(HttpMessageNotReadableException ex) {
String message = "Invalid request body: Unable to parse the provided data. Please ensure the values are " +
"correct and try again.";
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(message);
}
}
Creating Entity
If you run a POST request giving an empty string (“”) for the name value it will work. It’s not what we’d want for our API as a animal should have a name. Or giving it a future date of birth. To avoid that, we’re going to use Jakarta annotations to validate the incoming data.
First, we will write a validator for SDR and implement it in our entity, so we can validate the information before saving on DB:
1
public interface Validatable {}
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
@Component
@RepositoryEventHandler
@AllArgsConstructor
public class ValidationEventHandler {
private final Validator validator;
@HandleBeforeCreate
public <T extends Validatable> void validateBeforeCreate(@Valid T entity) {
validateEntity(entity);
}
@HandleBeforeSave
public <T extends Validatable> void validateBeforeSave(@Valid T entity) {
validateEntity(entity);
}
private <T extends Validatable> void validateEntity(T entity) {
Set<ConstraintViolation<T>> violations = validator.validate(entity);
if (!violations.isEmpty()) {
System.out.println(violations);
throw new ConstraintViolationException(violations);
}
}
}
Now change the Animal entity to the following:
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
@EntityListeners(AuditingEntityListener.class)
@Entity
@Table(name = "animals")
@Data
public class Animal implements Validatable {
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
@Column(name = "animal_id")
private Long id;
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
@CreatedDate
@Column(name = "created_date")
private Date createdDate;
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
@LastModifiedDate
@Column(name = "last_modified_date")
private Date lastModifiedDate;
@NotBlank(message = "Name is mandatory")
@Column(name = "name", length = 100, nullable = false)
private String name;
@Column(name = "description", length = 500)
private String description;
@Column(name = "image_url")
private String imageURL;
@NotBlank(message = "Category is mandatory")
@Column(name = "category", length = 100, nullable = false)
private String category;
@NotNull(message = "Birth Date is mandatory")
@PastOrPresent(message = "Birth Date can't be a future date")
@Column(name = "birth_date", nullable = false)
private LocalDate birthDate;
@NotNull(message = "Status is mandatory")
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private Status status;
public enum Status {DISPONIVEL, ADOTADO}
}
And add this new method to handle the ConstraintViolationException to our CustomRestExceptionHandler class.
1
2
3
4
5
6
7
8
9
10
11
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ResponseEntity<Map<String, String>> handleConstraintViolationException(ConstraintViolationException ex) {
Map<String, String> errors = new HashMap<>();
ex.getConstraintViolations().forEach(cv -> {
String fieldName = cv.getPropertyPath().toString();
String errorMessage = cv.getMessage();
errors.put(fieldName, errorMessage);
});
return new ResponseEntity<>(errors, HttpStatus.BAD_REQUEST);
}
Now, when sending a POST request with wrong data, we will receive a nice message informing what’s wrong.
Testing
To ensure our API is working as intended, we’re going to write some test cases.
Application Properties for Test
We’re using H2 in memory db for testing. That way we don’t need to mock the services and it gets cleaned after execution.
Create the file inside the test’s resources folder
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
spring:
datasource:
url: jdbc:h2:mem:public;DB_CLOSE_DELAY=-1;NON_KEYWORDS=KEY,VALUE
driver-class-name: org.h2.Driver
username: sa
password: password
jpa:
database-platform: org.hibernate.dialect.H2Dialect
show-sql: true
defer-datasource-initialization: true
hibernate:
ddl-auto: create
sql:
init:
mode: always
Status modification
For testing if updateStatus is working as intended, we will write a test for the AnimalService inside the test package.
Setting up
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@SpringBootTest
public class AnimalServiceTest {
@Autowired
private AnimalRepository animalRepository;
@Autowired
private AnimalService animalService;
private Animal animal;
@BeforeEach
public void setUp() {
animal = new Animal();
animal.setId(1L);
animal.setName("Bobby");
animal.setDescription("Cute dog");
animal.setImageURL("https://image.com/bobby");
animal.setCategory("Dog");
animal.setBirthDate(LocalDate.of(2020, 1, 1));
animal.setStatus(Animal.Status.DISPONIVEL);
animalRepository.save(animal);
}
Testing updateStatus functionality
1
2
3
4
5
6
7
8
9
10
11
12
@Test
public void testUpdateStatus() {
// Arrange
Animal.Status newStatus = Animal.Status.ADOTADO;
// Act
Animal updatedAnimal = animalService.updateStatus(animal.getId(), newStatus);
// Assert
assertEquals(newStatus, updatedAnimal.getStatus());
assertEquals(animal.getId(), updatedAnimal.getId());
}
Testing updateStatus exception
1
2
3
4
5
6
7
8
9
10
11
@Test
public void testUpdateStatus_AnimalNotFound() {
// Arrange
Long nonExistentId = 999L;
Animal.Status newStatus = Animal.Status.ADOTADO;
// Act & Assert
assertThrows(EntityNotFoundException.class, () -> {
animalService.updateStatus(nonExistentId, newStatus);
});
}
What we learned?
In this project you learned how to:
- Use Spring Data REST
- Hide fields from POST request
- Validate fields before saving
- Write custom error messages to avoid API exposition
- Create a custom projection
- Generate Swagger docs
- Use PostgreSQL with Docker Compose
- Write tests for our application
- Use H2 as our database for tests
That’s all for today!