From ace5ccc9768061abeaf40d5de76583956545a526 Mon Sep 17 00:00:00 2001 From: Matt Sicker Date: Thu, 7 Dec 2023 16:32:44 -0600 Subject: [PATCH] feat(api): Add ConstraintViolation API This adds a reusable API for surfacing constraint violations. The JavaEE/JakartaEE APIs for constraint violations is not aimed at REST APIs while this variation is. --- .../api/exceptions/ConstraintViolation.java | 34 +++++++ .../ConstraintViolationContext.java | 47 ++++++++++ .../DefaultConstraintViolationContext.java | 37 ++++++++ kork-web/kork-web.gradle | 1 + .../config/ErrorConfiguration.groovy | 15 +++ .../exceptions/ConstraintViolationAdvice.java | 94 +++++++++++++++++++ .../exceptions/ConstraintViolationError.java | 33 +++++++ .../ConstraintViolationResponse.java | 30 ++++++ 8 files changed, 291 insertions(+) create mode 100644 kork-api/src/main/java/com/netflix/spinnaker/kork/api/exceptions/ConstraintViolation.java create mode 100644 kork-api/src/main/java/com/netflix/spinnaker/kork/api/exceptions/ConstraintViolationContext.java create mode 100644 kork-api/src/main/java/com/netflix/spinnaker/kork/api/exceptions/DefaultConstraintViolationContext.java create mode 100644 kork-web/src/main/java/com/netflix/spinnaker/kork/web/exceptions/ConstraintViolationAdvice.java create mode 100644 kork-web/src/main/java/com/netflix/spinnaker/kork/web/exceptions/ConstraintViolationError.java create mode 100644 kork-web/src/main/java/com/netflix/spinnaker/kork/web/exceptions/ConstraintViolationResponse.java diff --git a/kork-api/src/main/java/com/netflix/spinnaker/kork/api/exceptions/ConstraintViolation.java b/kork-api/src/main/java/com/netflix/spinnaker/kork/api/exceptions/ConstraintViolation.java new file mode 100644 index 000000000..2d1fc140e --- /dev/null +++ b/kork-api/src/main/java/com/netflix/spinnaker/kork/api/exceptions/ConstraintViolation.java @@ -0,0 +1,34 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.kork.api.exceptions; + +import com.netflix.spinnaker.kork.annotations.NonnullByDefault; +import java.util.Map; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@NonnullByDefault +public class ConstraintViolation { + private final String message; + private final String errorCode; + private final String path; + private final Object validatedObject; + private final Object invalidValue; + @Builder.Default private final Map additionalAttributes = Map.of(); +} diff --git a/kork-api/src/main/java/com/netflix/spinnaker/kork/api/exceptions/ConstraintViolationContext.java b/kork-api/src/main/java/com/netflix/spinnaker/kork/api/exceptions/ConstraintViolationContext.java new file mode 100644 index 000000000..e30d13f0f --- /dev/null +++ b/kork-api/src/main/java/com/netflix/spinnaker/kork/api/exceptions/ConstraintViolationContext.java @@ -0,0 +1,47 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.kork.api.exceptions; + +import com.netflix.spinnaker.kork.annotations.NonnullByDefault; +import java.util.Iterator; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; + +/** + * Holder for additional {@link ConstraintViolation} metadata when performing constraint validation. + * A request-scoped bean of this class should be registered for use with an exception handler. + */ +@NonnullByDefault +public interface ConstraintViolationContext { + void addViolation(ConstraintViolation violation); + + default Optional removeMatchingViolation( + Predicate predicate) { + Iterator iterator = getConstraintViolations().iterator(); + while (iterator.hasNext()) { + ConstraintViolation violation = iterator.next(); + if (predicate.test(violation)) { + iterator.remove(); + return Optional.of(violation); + } + } + return Optional.empty(); + } + + List getConstraintViolations(); +} diff --git a/kork-api/src/main/java/com/netflix/spinnaker/kork/api/exceptions/DefaultConstraintViolationContext.java b/kork-api/src/main/java/com/netflix/spinnaker/kork/api/exceptions/DefaultConstraintViolationContext.java new file mode 100644 index 000000000..0a3392c58 --- /dev/null +++ b/kork-api/src/main/java/com/netflix/spinnaker/kork/api/exceptions/DefaultConstraintViolationContext.java @@ -0,0 +1,37 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.kork.api.exceptions; + +import com.netflix.spinnaker.kork.annotations.NonnullByDefault; +import java.util.ArrayList; +import java.util.List; +import lombok.Getter; + +/** + * Default implementation of {@link ConstraintViolationContext}. This should be registered as a + * request-scoped bean so that a request exception handler can use it. + */ +@Getter +@NonnullByDefault +public class DefaultConstraintViolationContext implements ConstraintViolationContext { + private final List constraintViolations = new ArrayList<>(); + + @Override + public void addViolation(ConstraintViolation violation) { + constraintViolations.add(violation); + } +} diff --git a/kork-web/kork-web.gradle b/kork-web/kork-web.gradle index 313f38191..c7d600944 100644 --- a/kork-web/kork-web.gradle +++ b/kork-web/kork-web.gradle @@ -17,6 +17,7 @@ dependencies { api project(":kork-security") api project(":kork-exceptions") api "org.codehaus.groovy:groovy" + api "org.springframework.boot:spring-boot-starter-validation" api "org.springframework.boot:spring-boot-starter-web" api "org.springframework.boot:spring-boot-starter-webflux" api "org.springframework.boot:spring-boot-starter-security" diff --git a/kork-web/src/main/groovy/com/netflix/spinnaker/config/ErrorConfiguration.groovy b/kork-web/src/main/groovy/com/netflix/spinnaker/config/ErrorConfiguration.groovy index 2f6ab8e9c..148d6a06f 100644 --- a/kork-web/src/main/groovy/com/netflix/spinnaker/config/ErrorConfiguration.groovy +++ b/kork-web/src/main/groovy/com/netflix/spinnaker/config/ErrorConfiguration.groovy @@ -16,8 +16,11 @@ package com.netflix.spinnaker.config +import com.netflix.spinnaker.kork.api.exceptions.ConstraintViolationContext +import com.netflix.spinnaker.kork.api.exceptions.DefaultConstraintViolationContext import com.netflix.spinnaker.kork.api.exceptions.ExceptionMessage import com.netflix.spinnaker.kork.web.controllers.GenericErrorController +import com.netflix.spinnaker.kork.web.exceptions.ConstraintViolationAdvice import com.netflix.spinnaker.kork.web.exceptions.ExceptionMessageDecorator import com.netflix.spinnaker.kork.web.exceptions.ExceptionSummaryService import com.netflix.spinnaker.kork.web.exceptions.GenericExceptionHandlers @@ -27,10 +30,22 @@ import org.springframework.boot.web.servlet.error.DefaultErrorAttributes import org.springframework.boot.web.servlet.error.ErrorAttributes import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.web.context.annotation.RequestScope import org.springframework.web.context.request.WebRequest @Configuration class ErrorConfiguration { + @Bean + @RequestScope + ConstraintViolationContext constraintViolationContext() { + new DefaultConstraintViolationContext() + } + + @Bean + ConstraintViolationAdvice constraintViolationAdvice(ConstraintViolationContext contextProvider) { + new ConstraintViolationAdvice(contextProvider) + } + @Bean ErrorAttributes errorAttributes() { final DefaultErrorAttributes defaultErrorAttributes = new DefaultErrorAttributes() diff --git a/kork-web/src/main/java/com/netflix/spinnaker/kork/web/exceptions/ConstraintViolationAdvice.java b/kork-web/src/main/java/com/netflix/spinnaker/kork/web/exceptions/ConstraintViolationAdvice.java new file mode 100644 index 000000000..1cdbcb920 --- /dev/null +++ b/kork-web/src/main/java/com/netflix/spinnaker/kork/web/exceptions/ConstraintViolationAdvice.java @@ -0,0 +1,94 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.kork.web.exceptions; + +import com.netflix.spinnaker.kork.api.exceptions.ConstraintViolationContext; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import javax.validation.ConstraintViolation; +import javax.validation.ConstraintViolationException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@ControllerAdvice +@RequiredArgsConstructor +public class ConstraintViolationAdvice { + private final ConstraintViolationContext context; + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException( + ConstraintViolationException e) { + ConstraintViolationResponse response = new ConstraintViolationResponse(); + List errors = combine(e.getConstraintViolations(), context); + response.setErrors(errors); + return ResponseEntity.badRequest().body(response); + } + + private static List combine( + Set> constraintViolations, ConstraintViolationContext context) { + var errors = + constraintViolations.stream() + .map( + constraintViolation -> + context + .removeMatchingViolation( + violation -> violationsMatch(constraintViolation, violation)) + .map( + violation -> + ConstraintViolationError.builder() + .message(violation.getMessage()) + .errorCode(violation.getErrorCode()) + .path(constraintViolation.getPropertyPath().toString()) + .additionalAttributes(violation.getAdditionalAttributes()) + .build()) + .orElseGet(() -> translate(constraintViolation))) + .collect(Collectors.toCollection(ArrayList::new)); + context.getConstraintViolations().stream() + .map(ConstraintViolationAdvice::translate) + .forEach(errors::add); + return errors; + } + + private static boolean violationsMatch( + ConstraintViolation constraintViolation, + com.netflix.spinnaker.kork.api.exceptions.ConstraintViolation violation) { + return constraintViolation.getLeafBean() == violation.getValidatedObject() + && constraintViolation.getMessageTemplate().equals(violation.getErrorCode()); + } + + private static ConstraintViolationError translate(ConstraintViolation violation) { + return ConstraintViolationError.builder() + .message(violation.getMessage()) + .errorCode(violation.getMessageTemplate()) + .path(violation.getPropertyPath().toString()) + .build(); + } + + private static ConstraintViolationError translate( + com.netflix.spinnaker.kork.api.exceptions.ConstraintViolation violation) { + return ConstraintViolationError.builder() + .message(violation.getMessage()) + .errorCode(violation.getErrorCode()) + .path(violation.getPath()) + .additionalAttributes(violation.getAdditionalAttributes()) + .build(); + } +} diff --git a/kork-web/src/main/java/com/netflix/spinnaker/kork/web/exceptions/ConstraintViolationError.java b/kork-web/src/main/java/com/netflix/spinnaker/kork/web/exceptions/ConstraintViolationError.java new file mode 100644 index 000000000..dd10d0e81 --- /dev/null +++ b/kork-web/src/main/java/com/netflix/spinnaker/kork/web/exceptions/ConstraintViolationError.java @@ -0,0 +1,33 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.kork.web.exceptions; + +import java.util.Map; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +/** Summarizes a {@link javax.validation.ConstraintViolation}. */ +@Getter +@ToString +@Builder +public class ConstraintViolationError { + private final String message; + private final String errorCode; + private final String path; + @Builder.Default private final Map additionalAttributes = Map.of(); +} diff --git a/kork-web/src/main/java/com/netflix/spinnaker/kork/web/exceptions/ConstraintViolationResponse.java b/kork-web/src/main/java/com/netflix/spinnaker/kork/web/exceptions/ConstraintViolationResponse.java new file mode 100644 index 000000000..4c8d3e1fa --- /dev/null +++ b/kork-web/src/main/java/com/netflix/spinnaker/kork/web/exceptions/ConstraintViolationResponse.java @@ -0,0 +1,30 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.kork.web.exceptions; + +import java.time.Instant; +import java.util.List; +import lombok.Getter; +import lombok.Setter; + +/** Summarizes a {@link javax.validation.ConstraintViolationException}. */ +@Getter +@Setter +public class ConstraintViolationResponse { + private Instant timestamp = Instant.now(); + private List errors; +}