· Jose Antonio López  · 4 min lectura

Com crear validadors personalitzats amb Hibernate i Spring Boot

Aprèn a implementar validacions personalitzades en Java amb Hibernate Validator, Bean Validation i Spring Boot.

Aprèn a implementar validacions personalitzades en Java amb Hibernate Validator, Bean Validation i Spring Boot.

Objectiu

La validació de dades és una part crítica de qualsevol aplicació. Tot i que Java Bean Validation (JSR 303/JSR 380) proporciona anotacions útils com @NotNull, @Size o @Email, sovint es necessiten regles de negoci més específiques.

En aquest article es descriu com crear un validador personalitzat per assegurar que les contrasenyes compleixin amb criteris de seguretat específics (longitud mínima, majúscules, números i caràcters especials). Per aprendre a gestionar de forma professional les excepcions llançades per aquests validadors, es recomana consultar la guia de maneig global d’excepcions.

Prerequisits

Per seguir aquest article es requereix tenir instal·lat:

Eina/PasEnllaç de descàrrega
Java JDK 21+Descarregar JDK
GradleGradle
Hibernate ValidatorHibernate Validator

Configuració del Projecte (build.gradle)

Per a aquest article s’utilitza Gradle. A continuació es mostren les línies més rellevants per construir un projecte amb suport de validació i Hibernate Validator:

build.gradle
plugins {
    id 'org.springframework.boot' version '3.5.1'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
}

group = 'com.safeboxes'
version = '1.0.0'
sourceCompatibility = '21'

repositories {
    mavenCentral()
}

dependencies {
    // Spring Boot
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation'

    // Testing
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    // Third-party libraries
    implementation 'org.hibernate.validator:hibernate-validator:9.1.0.Final'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}

tasks.named('test') {
    useJUnitPlatform()
}

test {
    testLogging {
        events "PASSED", "SKIPPED", "FAILED"
    }
}

Implementació

Directori i estructura de fitxers

Seguint una estructura de projecte neta, s’organitzen els validadors i controladors de la següent manera:

src
├── main
│   └── java
│       └── com/safeboxes
│           ├── config
│           │   └── ApiConfig.java
│           ├── domain
│           │   ├── exceptions
│           │   │   ├── SafeBoxException.java
│           │   │   └── enums
│           │   │       └── SafeBoxErrorMessage.java
│           │   ├── services
│           │   │   └── SafeBoxService.java
│           │   └── validators
│           │       ├── SecurePassword.java
│           │       └── SecurePasswordValidator.java
│           ├── dtos
│           │   └── safes
│           │       └── CreateSafeBoxDTO.java
│           └── infrastructure
│               └── controllers
│                   └── SafeBoxController.java
└── test
    └── java
        └── com/safeboxes
            └── domain/validators
                └── SecurePasswordValidatorTest.java

1. Definició de l’Anotació

Primer es crea la interfície de l’anotació. Aquesta defineix on es pot aplicar i quina classe s’encarregarà de la lògica de validació.

src/main/java/com/safeboxes/domain/validators/SecurePassword.java
package com.safeboxes.domain.validators;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.FIELD, ElementType.METHOD, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {SecurePasswordValidator.class})
public @interface SecurePassword {

    String message() default "Invalid Password";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

2. Implementació del Validador

La classe SecurePasswordValidator implementa ConstraintValidator, on resideix la lògica per verificar si la contrasenya és segura.

src/main/java/com/safeboxes/domain/validators/SecurePasswordValidator.java
package com.safeboxes.domain.validators;

import com.safeboxes.domain.exceptions.SafeBoxException;
import com.safeboxes.domain.exceptions.enums.SafeBoxErrorMessage;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.regex.Pattern;

public class SecurePasswordValidator implements ConstraintValidator<SecurePassword, String> {

    @Override
    public boolean isValid(String password, ConstraintValidatorContext context) {
        if (!isSecurePassword(password)) {
            SafeBoxErrorMessage error = SafeBoxErrorMessage.INVALID_PASSWORD_FORMAT;
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate(SafeBoxErrorMessage.getMessage(error));
            throw new SafeBoxException(error);
        }
        return true;
    }

    private boolean isSecurePassword(String password) {
        final int MINIMUM_PASSWORD_LENGTH = 12;
        if (password == null || password.length() < MINIMUM_PASSWORD_LENGTH) {
            return false;
        }
        
        final String UPPER_CASE_REG_EX = ".*[A-Z].*";
        final String LOWER_CASE_REG_EX = ".*[a-z].*";
        final String DIGIT_REG_EX = ".*\\d.*";
        final String SPECIAL_CHAR_REG_EX = ".*[! @#$%^&*()].*";

        return Pattern.matches(UPPER_CASE_REG_EX, password) &&
                Pattern.matches(LOWER_CASE_REG_EX, password) &&
                Pattern.matches(DIGIT_REG_EX, password) &&
                Pattern.matches(SPECIAL_CHAR_REG_EX, password);
    }
}

3. Ús del Validador en un DTO

S’aplica la nova anotació @SecurePassword en un DTO (Data Transfer Object) utilitzant un record de Java.

src/main/java/com/safeboxes/dtos/safes/CreateSafeBoxDTO.java
package com.safeboxes.dtos.safes;

import com.safeboxes.domain.validators.SecurePassword;
import jakarta.validation.constraints.NotNull;

public record CreateSafeBoxDTO(
    @NotNull String name,
    @NotNull @SecurePassword String password
)  {
}

4. Ús en el Controlador

Perquè la validació s’executi en rebre una petició, s’utilitza l’anotació @Valid al controlador de Spring Boot. S’empra Lombok amb @RequiredArgsConstructor per simplificar la injecció de dependències.

src/main/java/com/safeboxes/infrastructure/controllers/SafeBoxController.java
package com.safeboxes.infrastructure.controllers;

import com.safeboxes.config.ApiConfig;
import com.safeboxes.domain.services.SafeBoxService;
import com.safeboxes.dtos.safes.CreateSafeBoxDTO;
import com.safeboxes.dtos.safes.SafeBoxDTO;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor;

import jakarta.validation.Valid;

@RestController 
@RequestMapping(value = ApiConfig.BASE_PATH + "/safeboxes")
@RequiredArgsConstructor
public class SafeBoxController {
    private final SafeBoxService safeBoxService;

    @PostMapping
    public SafeBoxDTO createSafeBox(@Valid @RequestBody CreateSafeBoxDTO request) {
        return safeBoxService.createSafeBox(request);
    }
}

Tests Unitaris

És fonamental provar que el validador funcioni correctament per a tots els casos:

src/test/java/com/safeboxes/domain/validators/SecurePasswordValidatorTest.java
package com.safeboxes.domain.validators;

import com.safeboxes.domain.exceptions.SafeBoxException;
import com.safeboxes.domain.exceptions.enums.SafeBoxErrorMessage;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Answers;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import jakarta.validation.ConstraintValidatorContext;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

@ExtendWith(MockitoExtension.class)
class SecurePasswordValidatorTest {

    @InjectMocks
    private SecurePasswordValidator passwordValidator;

    @Mock(answer = Answers.RETURNS_DEEP_STUBS)
    private ConstraintValidatorContext context;

    @ParameterizedTest 
    @ValueSource(strings = {
            "short",              // Too short
            "123456789012",      // No letters
            "abcdefghijkl",      // No upper case
            "ABCDEFGHIJKL",      // No lower case
            "Abcdefghijkl",      // No digits
            "Abcdefghijk1",      // No special char
            "Abcdefghijk!",      // No digit
            "             ",      // Spaces
    })
    @NullSource 
    @DisplayName("Invalid passwords should throw SafeBoxException")
    void invalid_passwords_should_throw_exception(String password) {
        SafeBoxException exception = assertThrows(SafeBoxException.class, () -> {
            passwordValidator.isValid(password, context);
        });
        assertEquals(SafeBoxErrorMessage.INVALID_PASSWORD_FORMAT, exception.getErrorMessage());
    }

    @ParameterizedTest 
    @ValueSource(strings = {
            "ValidPass123!",
            "Secure#2026Password",
            "A1b2C3d4E5f!",
            "PasswordWithSpace 1!"
    })
    @DisplayName("Valid passwords should return true")
    void valid_passwords_should_return_true(String password) {
        assertTrue(passwordValidator.isValid(password, context));
    }

    @Test
    @DisplayName("should return true for a secure password")
    void secure_password_ok() {
        final String testPassword = "ValidPassword123!";
        assertTrue(passwordValidator.isValid(testPassword, context));
    }
}

Referències

  • Hibernate
  • Spring Boot
Compartir: