ProductUpdateValidator.java

package com.albertsilva.dev.asjcatalog.validation.product.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.product.request.ProductUpdateRequest;
import com.albertsilva.dev.asjcatalog.repository.CategoryRepository;
import com.albertsilva.dev.asjcatalog.repository.ProductRepository;
import com.albertsilva.dev.asjcatalog.validation.product.annotation.ProductUpdateValid;
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 ProductUpdateValid}: duas verificações sobre
 * {@code ProductUpdateRequest}, sempre executadas (acumulam violações).
 *
 * <ol>
 * <li><b>Nome não pertencente a outro produto:</b> 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>. Caso
 * contrário, converte o {@code id} com {@code Long.parseLong} (<b>sem tratar
 * {@code NumberFormatException}</b>) e consulta
 * {@code existsByNameIgnoreCaseAndIdNot(nomeNormalizado, id)}; violação em
 * {@code name} com {@code {product.name.unique}}. O id não é verificado como
 * existente.</li>
 * <li><b>Categorias existentes:</b> idêntica à de {@link ProductCreateValidator}
 * (uma consulta {@code existsById} por id; violação em {@code categoryIds}).</li>
 * </ol>
 *
 * <p>
 * Depende de uma variável de caminho chamada {@code id} e de execução dentro de
 * uma requisição MVC. Consulta o banco. Usa {@code FieldMessage} (pacote de
 * exceções da camada web) como estrutura de acúmulo.
 * </p>
 */
public class ProductUpdateValidator implements ConstraintValidator<ProductUpdateValid, ProductUpdateRequest> {

  /** Repositório de produtos, usado na verificação de nome de outro produto. */
  private final ProductRepository productRepository;

  /** Repositório de categorias, usado na verificação de existência dos ids. */
  private final CategoryRepository categoryRepository;

  /** 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 productRepository  repositório para acesso aos dados de produtos
   * @param categoryRepository repositório para acesso aos dados de categorias
   * @param request            objeto que representa a requisição HTTP atual,
   *                           utilizado para acessar os parâmetros da URI
   */
  public ProductUpdateValidator(ProductRepository productRepository, CategoryRepository categoryRepository,
      HttpServletRequest request) {
    this.productRepository = productRepository;
    this.categoryRepository = categoryRepository;
    this.request = request;
  }

  /**
   * Verifica se o nome pertence a outro produto (id da URL) e se as categorias
   * existem, acumulando as violações.
   *
   * @param dto     dados de atualização
   * @param context contexto usado para registrar as violações nos campos
   *                {@code name} e {@code categoryIds}
   * @return {@code true} se nenhuma violação for encontrada
   */
  @Override
  public boolean isValid(ProductUpdateRequest dto, ConstraintValidatorContext context) {

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

    validateUniqueName(dto, errors);
    validateCategories(dto, errors);

    addErrors(errors, context);

    return errors.isEmpty();
  }

  /**
   * Valida se já existe outro produto cadastrado
   * com o mesmo nome informado.
   *
   * <p>
   * Durante a verificação, o produto atualmente
   * sendo atualizado é desconsiderado na consulta.
   *
   * <p>
   * Antes da validação, o nome informado é normalizado:
   * <ul>
   * <li>{@code trim()} (remove espaços apenas nas extremidades)</li>
   * <li>Convertendo para letras minúsculas</li>
   * </ul>
   *
   * <p>
   * O identificador do produto é obtido a partir
   * dos parâmetros presentes na URI da requisição.
   *
   * @param dto    objeto contendo os dados do produto
   * @param errors lista responsável por armazenar
   *               os erros encontrados durante a validação
   */
  @SuppressWarnings("unchecked")
  private void validateUniqueName(ProductUpdateRequest 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 productId = Long.parseLong(uriVars.get("id"));

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

    boolean productAlreadyExists = productRepository.existsByNameIgnoreCaseAndIdNot(normalizedName, productId);

    if (productAlreadyExists) {

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

  /**
   * Valida se todas as categorias informadas
   * no produto existem na base de dados.
   *
   * <p>
   * A validação percorre todos os identificadores
   * de categorias informados e verifica individualmente
   * sua existência.
   *
   * <p>
   * Caso ao menos uma categoria não exista,
   * uma mensagem de erro é adicionada à lista
   * de erros.
   *
   * @param dto    objeto contendo os dados do produto
   * @param errors lista responsável por armazenar
   *               os erros encontrados durante a validação
   */
  private void validateCategories(ProductUpdateRequest dto, List<FieldMessage> errors) {

    if (dto.categoryIds() == null || dto.categoryIds().isEmpty()) {
      return;
    }

    boolean invalidCategory = dto.categoryIds().stream().anyMatch(id -> !categoryRepository.existsById(id));

    if (invalidCategory) {

      errors.add(new FieldMessage("categoryIds", "{product.categoryIds.invalid}"));
    }
  }

  /**
   * 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();
    }
  }
}