RentalPatchOperationValidator.java

package com.github.jenkaby.bikerental.rental.web.command.dto.validation;

import com.github.jenkaby.bikerental.rental.web.command.dto.RentalPatchOperation;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

import java.time.Duration;
import java.util.Set;
import java.util.regex.Pattern;


public class RentalPatchOperationValidator implements ConstraintValidator<ValidRentalPatchOperation, RentalPatchOperation> {

    private static final Set<String> ALLOWED_PATHS = Set.of(
            "/customerId",
            "/equipmentIds",
            "/tariffId",
            "/duration",
            "/status"
    );

    private static final Pattern IS_ARRAY_OF_NUMBERS = Pattern.compile("^\\s*\\[\\s*(?:\\d+\\s*(?:,\\s*\\d+\\s*)*)?\\]\\s*$");

    @Override
    public boolean isValid(RentalPatchOperation operation, ConstraintValidatorContext context) {
        if (operation == null) {
            return true;
        }

        boolean isValid = true;
        context.disableDefaultConstraintViolation();

        // Validate operation type
        if (operation.getOp() == null) {
            context.buildConstraintViolationWithTemplate("Operation 'op' is required")
                    .addPropertyNode("op")
                    .addConstraintViolation();
            isValid = false;
        }

        // Validate path
        if (operation.getPath() == null || operation.getPath().isBlank()) {
            context.buildConstraintViolationWithTemplate("Path is required")
                    .addPropertyNode("path")
                    .addConstraintViolation();
            isValid = false;
        } else if (!operation.getPath().startsWith("/")) {
            context.buildConstraintViolationWithTemplate("Path must start with '/'")
                    .addPropertyNode("path")
                    .addConstraintViolation();
            isValid = false;
        } else if (!ALLOWED_PATHS.contains(operation.getPath())) {
            context.buildConstraintViolationWithTemplate(
                            String.format("Path '%s' is not allowed. Allowed paths: %s",
                                    operation.getPath(), ALLOWED_PATHS))
                    .addPropertyNode("path")
                    .addConstraintViolation();
            isValid = false;
        }

        // Validate value is provided (all supported operations require a value)
        if (operation.getOp() != null && operation.getValue() == null) {
            context.buildConstraintViolationWithTemplate(
                            String.format("Value is required for operation '%s'", operation.getOp().getValue()))
                    .addPropertyNode("value")
                    .addConstraintViolation();
            isValid = false;
        }
        if (operation.getOp() != null && "/duration".equals(operation.getPath()) && operation.getValue() != null) {
            // For duration, value must be a valid ISO-8601 duration string
            try {
                Duration.parse(operation.getValue().toString());
            } catch (Exception e) {
                context.buildConstraintViolationWithTemplate(
                                "Value for path '/duration' must be a valid ISO-8601 duration string, e.g. 'PT1H30M'")
                        .addPropertyNode("value")
                        .addConstraintViolation();
                isValid = false;
            }
        }
        if (operation.getOp() != null && "/equipmentIds".equals(operation.getPath()) && operation.getValue() != null) {
            if (!IS_ARRAY_OF_NUMBERS.matcher(operation.getValue().toString()).matches()) {
                context.buildConstraintViolationWithTemplate(
                                "Value for path '/equipmentIds' must be an array of int64")
                        .addPropertyNode("value")
                        .addConstraintViolation();
                isValid = false;
            }
        }
        return isValid;
    }
}