StrictBodyConverter.java

package net.hostsharing.hsadminng.mapper;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.exc.ValueInstantiationException;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator;
import net.hostsharing.hsadminng.errors.MultiValidationException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;

import java.io.IOException;
import java.util.Arrays;
import java.util.function.Function;
import java.util.stream.Collectors;

/**
 * Applies the OpenAPI schema constraints to request bodies which the generator emits as a bare {@code Object}
 * (e.g. {@code anyOf} bodies), where {@code @Valid} thus cannot run.
 *
 * <p>The untyped body is strictly converted to the given generated resource class — rejecting unknown
 * properties ({@code additionalProperties: false}) and invalid enum values — and then bean-validated
 * programmatically, so the annotations generated from the OpenAPI schema ({@code @NotNull}, {@code @Pattern})
 * fire just like {@code @Valid} would. Violations are rejected with a 400 response.</p>
 *
 * <p>For {@code anyOf} bodies, the caller dispatches on its discriminator property to the matching
 * generated member resource class; a custom violation-message function allows domain-specific
 * (e.g. translated) error messages.</p>
 */
@Component
public class StrictBodyConverter {

    private final ObjectMapper objectMapper;
    private final Validator validator;

    public StrictBodyConverter(@Autowired final ObjectMapper objectMapper, @Autowired final Validator validator) {
        this.objectMapper = objectMapper;
        this.validator = validator;
    }

    public <R> R convertAndValidate(final Object body, final Class<R> resourceClass) {
        return convertAndValidate(body, resourceClass, StrictBodyConverter::defaultViolationMessage);
    }

    public <R> R convertAndValidate(
            final Object body,
            final Class<R> resourceClass,
            final Function<ConstraintViolation<R>, String> violationMessage) {
        final var resource = strictConvert(body, resourceClass);
        MultiValidationException.throwIfNotEmpty(
                validator.validate(resource).stream()
                        .map(violationMessage)
                        .toList());
        return resource;
    }

    public static String defaultViolationMessage(final ConstraintViolation<?> violation) {
        return violation.getPropertyPath() + " " + violation.getMessage();
    }

    private <R> R strictConvert(final Object body, final Class<R> resourceClass) {
        try {
            final JsonNode bodyTree = objectMapper.valueToTree(body);
            // rejecting unknown properties enforces the schema's `additionalProperties: false`
            return objectMapper.readerFor(resourceClass)
                    .with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
                    .readValue(bodyTree);
        } catch (final ValueInstantiationException exc) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, invalidValueMessage(exc), exc);
        } catch (final IOException | IllegalArgumentException exc) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, exc.getMessage(), exc);
        }
    }

    // e.g. `scopes[1] 'foo:bar' is not valid, valid values are: [rbac.subjects:sync, *:read]`
    // instead of Jackson's "Cannot construct instance of ..." with the full reference chain
    private String invalidValueMessage(final ValueInstantiationException exc) {
        final var path = exc.getPath().stream()
                .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "[" + ref.getIndex() + "]")
                .collect(Collectors.joining());
        final var targetClass = exc.getType().getRawClass();
        if (targetClass.isEnum()) {
            // the generated enums' @JsonCreator throws IllegalArgumentException(value), thus
            // the cause's message is the invalid value itself
            final var invalidValue = exc.getCause() != null ? exc.getCause().getMessage() : exc.getOriginalMessage();
            final var validValues = Arrays.stream(targetClass.getEnumConstants())
                    .map(constant -> objectMapper.convertValue(constant, String.class))
                    .toList();
            return path + " '" + invalidValue + "' is not valid, valid values are: " + validValues;
        }
        return path + " " + exc.getOriginalMessage();
    }
}