Spring Boot validation with SPA frontend (part 1)
January 18, 2023
by
Rainer Burgstaller
Creating a spring boot app with a SPA frontend and end-to-end validation
In this three part tutorial we will be creating a simple JPA Rest Repository based application with spring boot.
In the upcoming parts we will connect a TypeScript frontend to it
and explore how to properly handle validation with Jakarta Bean Validation and zod on the client side.
In addition we will explore some best practices on Entity modeling
(e.g. the significance of equals
/hashCode
, choosing good primary keys, etc.).
Finally, we obviously are using the latest spring boot version 3.0.1 together with
hibernate 6.1.6.
Creating the server
To create the server go to https://start.spring.io and create a project with the
following dependencies
- Lombok
- Spring Data JPA
- H2 database
- Rest Repository
- Validation
- Spring Boot Actuator
Choosing a good ID
Typically we would be choosing either a Long
or a UUID
for the primary key of our
entities, however, Long
s are problematic as they allow an attacker to easily
guess the ID
of other entities, and UUID
s don’t have an inherent sorting order
which makes them a poor choice for B+Tree index structures (see also this article).
Therefore, we will be using Ulid
as a primary key (see also this article, or this, or JPA Buddy).
1
|
implementation("com.github.f4b6a3:ulid-creator:5.1.0")
|
In addition we shamelessly copy the KeyGenerator
class from the article.
1
2
3
4
5
6
7
|
public class KeyGenerator implements IdentifierGenerator {
@Override
public Object generate(SharedSessionContractImplementor session, Object object) throws HibernateException {
return UlidCreator.getUlid().toString();
}
}
|
Creating the data model
Let’s create our entity for the 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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
@Entity
@Table(uniqueConstraints = {
@UniqueConstraint(name = "clubNameUnique", columnNames = {"clubName"})
})
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
public class Club {
@Id
@Column(updatable = false)
@GenericGenerator(name = "ulid_gen",
strategy = "com.example.springbootvalidation.util.KeyGenerator")
@GeneratedValue(generator = "ulid_gen")
String id;
@Column
@NotBlank
@Length(min = 3, max = 150)
String clubName;
@Column
@Email
@NotBlank
@Length(max = 200)
String managerEmail;
public Club(String clubName, String managerEmail) {
this.clubName = clubName;
this.managerEmail = managerEmail;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
Club club = (Club) o;
return id != null && Objects.equals(id, club.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
|
Note that the equals
and hashCode
methods were created using the
JPA Buddy plugin and are in line with
the recommendations.
Further note that we will let Hibernate create the DB schema automatically, which is
good for a demo, but for production you would use something like
liquibase or flyway.
Finally, we are using a custom IdentifierGenerator
to auto-populate the
id
for newly created Club
entities (see also this article).
In addition to the entity, we obviously need a Repository
1
|
public interface ClubRepository extends JpaRepository<Club, String> {}
|
Since we are using an in-memory h2 database, we should
ingest some data upon application startup.
So we simply add an ApplicationRunner
into our SpringBootValidationApplication
1
2
3
4
5
6
7
|
@Bean
ApplicationRunner dataIngester(ClubRepository repository) {
return args -> {
repository.save(new Club("club1", "manager@club1.com"));
repository.save(new Club("club2", "manager@club2.com"));
};
}
|
Finally, we will disable open-in-view
in application.properties
since this is
generally a good idea.
1
|
spring.jpa.open-in-view=false
|
Creating the REST interface
We will be using spring data rest repositories,
therefore, the implementation is trivial.
All we need to do is add a single annotation to
our ClubRepository
:
1
2
|
@RepositoryRestResource
public interface ClubRepository extends JpaRepository<Club, String> {}
|
In addition, we make the repository available under the /api
path
which will facilitate our client application integration.
Therefore, simply add the following line to application.properties
1
|
spring.data.rest.basePath=/api
|
Testing the REST interface
Now we should have everything ready to be able
to query our REST interface. We will be using httpie
and get the following result:
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
|
HTTP/1.1 200
Connection: keep-alive
Content-Type: application/hal+json
Date: Mon, 26 Dec 2022 09:39:54 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
{
"_embedded": {
"clubs": [
{
"_links": {
"club": {
"href": "http://localhost:8080/api/clubs/395148707155466489"
},
"self": {
"href": "http://localhost:8080/api/clubs/395148707155466489"
}
},
"clubName": "club1",
"managerEmail": "manager@club1.com"
},
{
"_links": {
"club": {
"href": "http://localhost:8080/api/clubs/395148707482618713"
},
"self": {
"href": "http://localhost:8080/api/clubs/395148707482618713"
}
},
"clubName": "club2",
"managerEmail": "manager@club2.com"
}
]
},
"_links": {
"profile": {
"href": "http://localhost:8080/api/profile/clubs"
},
"self": {
"href": "http://localhost:8080/api/clubs"
}
},
"page": {
"number": 0,
"size": 20,
"totalElements": 2,
"totalPages": 1
}
}
|
This looks already quite good, but we are missing the id
which
we would like to use in the client (without having to parse it out
of the self
link).
Adding id
to the responses
This can be achieved by implementing RepositoryRestConfigurer
like so
1
2
3
4
5
6
7
8
9
10
11
12
13
|
@Configuration
@RequiredArgsConstructor
class RestConfiguration implements RepositoryRestConfigurer {
private final EntityManager entityManager;
@Override
public void configureRepositoryRestConfiguration(
RepositoryRestConfiguration config, CorsRegistry cors) {
Class[] classes = entityManager.getMetamodel()
.getEntities().stream().map(Type::getJavaType).toArray(Class[]::new);
config.exposeIdsFor(classes);
}
}
|
This basically exposes the id
for all JPA entities.
If we try the httpie
call from above we should now get
the id
s along with the rest of the data
1
2
3
4
5
6
7
8
|
{
"_embedded": {
"clubs": [
{
"_links": {...},
"clubName": "club1",
"id": 395150462955123467,
"managerEmail": "manager@club1.com"
|
Testing the validation
To check whether our validation annotations work we will
try to insert invalid data into the database via the REST interface.
Since the clubName
must be at least 3 characters long
we will try to insert one with 2 characters:
1
|
http :8080/api/clubs clubName=fo managerEmail=manager@email.com
|
and we get the following response
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
HTTP/1.1 500
Connection: close
Content-Type: application/json
Date: Mon, 26 Dec 2022 09:49:28 GMT
Transfer-Encoding: chunked
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
{
"error": "Internal Server Error",
"path": "/api/clubs",
"status": 500,
"timestamp": "2022-12-26T09:49:28.567+00:00"
}
|
Well thats not very helpful. In the completed application
we want to annotate the entry form with the validation
errors. Therefore, a blank 500
error won’t do.
Serializing validation errors
In order to be able to control the error responses we need to implement
ResponseEntityExceptionHandler
as documented here.
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
|
@ControllerAdvice
class RestResponseEntityExceptionHandler extends
ResponseEntityExceptionHandler {
@ExceptionHandler({ConstraintViolationException.class})
public ResponseEntity<Object> handleConstraintViolation(
Exception ex, WebRequest request) {
ConstraintViolationException nevEx = findException(ex, ConstraintViolationException.class);
Map<String, Map<String, String>> errs = new HashMap<>();
nevEx.getConstraintViolations().stream()
.forEach(constraintViolation -> errs.put(constraintViolation.getPropertyPath().toString(), Map.of("message", constraintViolation.getMessage())));
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "validation failed");
problemDetail.setProperty("errors", errs);
problemDetail.setType(URI.create("https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400"));
return ResponseEntity.badRequest().body(problemDetail);
}
private <T extends Throwable> T findException(Throwable throwable, Class<T> expected) {
Throwable cur = throwable;
while (cur != null) {
if (expected.isAssignableFrom(cur.getClass())) {
return ((T) cur);
}
cur = cur.getCause();
}
throw new IllegalArgumentException("throwable %s is not of type %s".formatted(throwable, expected));
}
}
|
If we now try our POST
again
1
|
http :8080/api/clubs clubName=fo managerEmail=manager@email.com
|
we now get a nicely formatted 400
error:
1
2
3
4
5
6
7
8
9
10
11
12
|
{
"detail": "validation failed",
"errors": {
"clubName": {
"message": "length must be between 3 and 150"
}
},
"instance": "/api/clubs",
"status": 400,
"title": "Bad Request",
"type": "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400"
}
|
So with this the server is now fully prepared to serve content for our frontend app.
Continue to part 2 of this tutorial.
The code can be found at https://github.com/rburgst/spring-boot-validation
share this blogpost
https://blog.e-mundo.de/post/spring-boot-validation-with-spa-frontend-part-1/
copied to clipboard