UserUpdateValidator.java
package com.albertsilva.dev.asjcatalog.validation.user.validator;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.springframework.web.servlet.HandlerMapping;
import com.albertsilva.dev.asjcatalog.dto.user.request.UserUpdateRequest;
import com.albertsilva.dev.asjcatalog.repository.UserRepository;
import com.albertsilva.dev.asjcatalog.validation.user.annotation.UserUpdateValid;
import com.albertsilva.dev.asjcatalog.web.exception.response.FieldMessage;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
/**
* Validator de {@link UserUpdateValid}: duas verificações sobre
* {@code UserUpdateRequest}.
*
* <ol>
* <li><b>E-mail único, excluindo o próprio usuário:</b> lê o id de
* {@code HttpServletRequest} (atributo de variáveis de URI do Spring MVC,
* chave {@code id}). Se o e-mail for {@code null}/em branco, se o mapa ou a
* chave {@code id} não existirem, ou se o id não for numérico
* ({@code NumberFormatException} capturada), a verificação é <b>ignorada</b>
* (sem violação). Caso contrário, normaliza o e-mail ({@code trim()} +
* {@code toLowerCase()}) e consulta
* {@code existsByEmailIgnoreCaseAndIdNot(email, id)}; violação em
* {@code email} com {@code {user.email.unique}}. O id <b>não</b> é verificado
* como existente: com id inexistente, qualquer e-mail já cadastrado é
* considerado duplicado.</li>
* <li><b>Senha sem dados pessoais:</b> só se a senha não for {@code null}
* nem em branco; mesma regra de {@link PasswordPersonalDataValidator}
* (substring, tokens de ao menos 3 caracteres, no
* máximo uma violação em {@code password}).</li>
* </ol>
*
* <p>
* Dependências: {@code UserRepository} e {@code HttpServletRequest}
* (injetado pelo Spring; o validator é criado como bean e recebe a requisição
* corrente). A lógica de dados pessoais é uma cópia da de
* {@link PasswordPersonalDataValidator}, e o e-mail original não é modificado.
* </p>
*/
public class UserUpdateValidator implements ConstraintValidator<UserUpdateValid, UserUpdateRequest> {
/**
* Tamanho mínimo que um token (nome/sobrenome/email prefix)
* deve ter para ser considerado na validação da senha.
*/
private static final int MIN_TOKEN_LENGTH = 3;
/**
* Repositório utilizado para consultar dados
* de usuários no banco de dados.
*/
private final UserRepository repository;
/**
* Requisição HTTP atual, utilizada para extrair
* o ID do usuário a ser atualizado a partir
* das variáveis de template da URI.
*/
private final HttpServletRequest request;
/**
* Construtor que recebe o repositório de usuários e
* a requisição HTTP por injeção de dependência.
*
* @param repository repositório responsável pelas
* operações de busca no banco de dados
* @param request requisição HTTP atual, utilizada para
* extrair informações de contexto
*/
public UserUpdateValidator(UserRepository repository, HttpServletRequest request) {
this.repository = repository;
this.request = request;
}
/**
* Executa a verificação de e-mail único (excluindo o usuário da URL) e a de
* dados pessoais na senha, acumulando todas as violações.
*
* @param dto dados de atualização
* @param context contexto usado para registrar as violações nos campos
* {@code email} e {@code password}
* @return {@code true} se nenhuma violação for encontrada
*/
@Override
public boolean isValid(UserUpdateRequest dto, ConstraintValidatorContext context) {
List<FieldMessage> errors = new ArrayList<>();
validateUniqueEmail(dto, errors);
validatePasswordDoesNotContainPersonalData(dto, errors);
addErrors(errors, context);
return errors.isEmpty();
}
/**
* Valida se o email fornecido é único no banco de dados,
* ignorando o usuário sendo atualizado.
*
* <p>
* O método extrai o ID do usuário a ser atualizado
* a partir das variáveis de template da URI da requisição HTTP.
* Caso o ID não seja encontrado ou seja inválido, a validação
* é ignorada.
*
* <p>
* O email é normalizado para minúsculas e espaços em branco
* são removidos antes da consulta.
*
* @param dto objeto contendo os dados de atualização do usuário
* @param errors lista responsável por armazenar
* os erros encontrados
*/
private void validateUniqueEmail(UserUpdateRequest dto, List<FieldMessage> errors) {
if (dto.email() == null || dto.email().isBlank()) {
return;
}
@SuppressWarnings("unchecked")
Map<String, String> uriVars = (Map<String, String>) request
.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
if (uriVars == null || !uriVars.containsKey("id")) {
return;
}
Long userId;
try {
userId = Long.parseLong(uriVars.get("id"));
} catch (NumberFormatException e) {
return;
}
String normalizedEmail = dto.email().trim().toLowerCase();
boolean emailAlreadyExists = repository.existsByEmailIgnoreCaseAndIdNot(normalizedEmail, userId);
if (emailAlreadyExists) {
errors.add(new FieldMessage("email", "{user.email.unique}"));
}
}
/**
* Valida se a senha não contém dados pessoais do usuário
* como nome, sobrenome ou parte local do email.
*
* <p>
* Caso a senha seja {@code null} ou esteja em branco (vazia ou só com
* espaços), a verificação é ignorada.
*
* @param dto objeto contendo os dados de atualização do usuário
* @param errors lista responsável por armazenar
* os erros encontrados
*/
private void validatePasswordDoesNotContainPersonalData(UserUpdateRequest dto, List<FieldMessage> errors) {
if (dto.password() == null || dto.password().isBlank()) {
return;
}
String password = dto.password().trim().toLowerCase();
validateToken(password, dto.firstName(), errors);
validateToken(password, dto.lastName(), errors);
if (dto.email() != null && dto.email().contains("@")) {
String emailPrefix = dto.email().split("@")[0];
validateToken(password, emailPrefix, errors);
}
}
/**
* Valida se a senha contém um token específico (nome, sobrenome ou
* parte local do email) de forma case-insensitive.
*
* <p>
* O token é considerado válido apenas se possuir um tamanho
* mínimo definido por {@link #MIN_TOKEN_LENGTH}. Caso contrário,
* a validação é ignorada.
*
* <p>
* Se a senha contiver o token normalizado, um erro é adicionado
* à lista de erros, evitando duplicatas.
*
* @param password senha normalizada em minúsculas
* @param value valor do token a ser verificado (nome, sobrenome, etc)
* @param errors lista responsável por armazenar
* os erros encontrados
*/
private void validateToken(String password, String value, List<FieldMessage> errors) {
if (value == null) {
return;
}
String normalized = value.trim().toLowerCase();
if (normalized.length() < MIN_TOKEN_LENGTH) {
return;
}
boolean alreadyExists = errors.stream().anyMatch(
error -> error.fieldName().equals("password") && error.message().equals("{user.password.personalData}"));
if (password.contains(normalized) && !alreadyExists) {
errors.add(new FieldMessage("password", "{user.password.personalData}"));
}
}
/**
* Adiciona ao contexto de validação todos os erros
* encontrados durante o processo de validação.
*
* <p>
* O método desabilita a mensagem padrão do Bean Validation
* para permitir o registro de mensagens customizadas e
* associar cada erro ao campo específico via
* {@link ConstraintValidatorContext#buildConstraintViolationWithTemplate(String)}.
*
* @param errors lista contendo os erros encontrados
* @param context contexto utilizado para registrar
* as violações de validação
*/
private void addErrors(List<FieldMessage> errors, ConstraintValidatorContext context) {
if (errors.isEmpty()) {
return;
}
context.disableDefaultConstraintViolation();
for (FieldMessage error : errors) {
context.buildConstraintViolationWithTemplate(error.message()).addPropertyNode(error.fieldName())
.addConstraintViolation();
}
}
}