CategoryUpdateValidator.java

package com.albertsilva.dev.asjcatalog.validation.category.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.category.request.CategoryUpdateRequest;
import com.albertsilva.dev.asjcatalog.repository.CategoryRepository;
import com.albertsilva.dev.asjcatalog.validation.category.annotation.CategoryUpdateValid;
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 CategoryUpdateValid}: verifica se o nome já pertence a
 * <b>outra</b> categoria.
 *
 * <p>
 * Se o nome for {@code null}/em branco, ou se {@code HttpServletRequest} não
 * tiver o atributo de variáveis de URI ou a chave {@code id}, <b>nada é
 * verificado</b> (resultado válido). Caso contrário, converte o {@code id} com
 * {@code Long.parseLong} — <b>sem tratar {@code NumberFormatException}</b> — e
 * consulta {@code existsByNameIgnoreCaseAndIdNot(nomeNormalizado, id)}
 * ({@code trim()} + {@code toLowerCase()}); violação em {@code name} com
 * {@code {category.name.unique}}. O id não é verificado como existente: com id
 * inexistente, qualquer nome já cadastrado é considerado duplicado.
 * </p>
 *
 * <p>
 * Depende do formato atual das rotas (uma variável de caminho chamada
 * {@code id}) e de estar sendo executado dentro de uma requisição MVC. Se o
 * {@code id} vier não numérico, é provável que o Spring já tenha rejeitado a
 * requisição ao converter o {@code @PathVariable}, antes deste validator (a
 * confirmar na camada web).
 * </p>
 */
public class CategoryUpdateValidator implements ConstraintValidator<CategoryUpdateValid, CategoryUpdateRequest> {

  /** Repositório usado para verificar a existência de outra categoria com o mesmo nome. */
  private final CategoryRepository repository;

  /** Requisição HTTP corrente, de onde se lê a variável de caminho {@code id}. */
  private final HttpServletRequest request;

  /**
   * Construtor para injeção de dependências.
   *
   * @param repository repositório de categorias utilizado
   *                   para consultas durante a validação
   * @param request    objeto representando a requisição HTTP
   *                   atual, utilizado para acessar parâmetros
   *                   da URI durante a validação
   */
  public CategoryUpdateValidator(CategoryRepository repository,
      HttpServletRequest request) {
    this.repository = repository;
    this.request = request;
  }

  /**
   * Verifica se o nome já pertence a outra categoria (o id vem da URL).
   *
   * @param dto     dados de atualização
   * @param context contexto usado para registrar a violação no campo
   *                {@code name}
   * @return {@code true} se não houver duplicidade ou se a verificação for
   *         ignorada
   */
  @Override
  public boolean isValid(CategoryUpdateRequest dto, ConstraintValidatorContext context) {

    List<FieldMessage> errors = new ArrayList<>();

    validateUniqueName(dto, errors);

    addErrors(errors, context);

    return errors.isEmpty();
  }

  /**
   * Valida se já existe outra categoria cadastrada
   * com o mesmo nome informado.
   *
   * <p>
   * Durante a verificação, a categoria atualmente
   * sendo atualizada é desconsiderada na consulta.
   *
   * <p>
   * Antes da consulta, o nome informado é normalizado:
   * <ul>
   * <li>{@code trim()} (remove espaços apenas nas extremidades)</li>
   * <li>{@code toLowerCase()} (sem {@code Locale})</li>
   * </ul>
   *
   * <p>
   * O identificador da categoria é obtido a partir
   * dos parâmetros presentes na URI da requisição.
   *
   * @param dto    objeto contendo os dados da categoria
   * @param errors lista responsável por armazenar
   *               os erros encontrados durante a validação
   */
  @SuppressWarnings("unchecked")
  private void validateUniqueName(CategoryUpdateRequest dto, List<FieldMessage> errors) {

    if (dto.name() == null || dto.name().isBlank()) {
      return;
    }

    Map<String, String> uriVars = (Map<String, String>) request
        .getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);

    if (uriVars == null || !uriVars.containsKey("id")) {
      return;
    }

    Long categoryId = Long.parseLong(uriVars.get("id"));

    String normalizedName = dto.name().trim().toLowerCase();

    boolean categoryAlreadyExists = repository.existsByNameIgnoreCaseAndIdNot(normalizedName, categoryId);

    if (categoryAlreadyExists) {

      errors.add(new FieldMessage("name", "{category.name.unique}"));
    }
  }

  /**
   * 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
   * associadas a campos específicos.
   *
   * @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();
    }
  }
}