Spring Boot validation with SPA frontend (part 1)

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

start.spring.io configuration

Choosing a good ID

Typically we would be choosing either a Long or a UUID for the primary key of our entities, however, Longs are problematic as they allow an attacker to easily guess the ID of other entities, and UUIDs 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

1
http :8080/api/clubs

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 ids 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

About the author
Rainer Burgstaller is an architect, full stack developer on the endless quest to maximise developer productivity.

read more from Rainer