diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/Producible.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/Producible.java index fc2c48742216..2bfe151766b0 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/Producible.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/Producible.java @@ -47,4 +47,16 @@ public interface Producible & Producible> { */ MimeType getProducedMimeType(); + /** + * Return if this enum value should be used as the default value when an accept header + * of */* is provided, or if the accept header is missing. Only one value + * can be marked as default. If no value is marked, then the value with the highest + * {@link Enum#ordinal() ordinal} is used as the default. + * @return if this value + * @since 2.5.6 + */ + default boolean isDefault() { + return false; + } + } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/ProducibleOperationArgumentResolver.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/ProducibleOperationArgumentResolver.java index e407286c7f6f..6e9b639a2f88 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/ProducibleOperationArgumentResolver.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/ProducibleOperationArgumentResolver.java @@ -21,6 +21,7 @@ import java.util.List; import java.util.function.Supplier; +import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.MimeType; import org.springframework.util.MimeTypeUtils; @@ -29,6 +30,7 @@ * An {@link OperationArgumentResolver} for {@link Producible producible enums}. * * @author Andy Wilkinson + * @author Phillip Webb * @since 2.5.0 */ public class ProducibleOperationArgumentResolver implements OperationArgumentResolver { @@ -56,30 +58,35 @@ public T resolve(Class type) { private Enum> resolveProducible(Class>> type) { List accepts = this.accepts.get(); - List>> values = Arrays.asList(type.getEnumConstants()); - Collections.reverse(values); + List>> values = getValues(type); if (CollectionUtils.isEmpty(accepts)) { - return values.get(0); + return getDefaultValue(values); } Enum> result = null; for (String accept : accepts) { for (String mimeType : MimeTypeUtils.tokenize(accept)) { - result = mostRecent(result, forType(values, MimeTypeUtils.parseMimeType(mimeType))); + result = mostRecent(result, forMimeType(values, mimeType)); } } return result; } - private static Enum> mostRecent(Enum> existing, + private Enum> mostRecent(Enum> existing, Enum> candidate) { int existingOrdinal = (existing != null) ? existing.ordinal() : -1; int candidateOrdinal = (candidate != null) ? candidate.ordinal() : -1; return (candidateOrdinal > existingOrdinal) ? candidate : existing; } - private static Enum> forType(List>> candidates, - MimeType mimeType) { - for (Enum> candidate : candidates) { + private Enum> forMimeType(List>> values, String mimeType) { + if ("*/*".equals(mimeType)) { + return getDefaultValue(values); + } + return forMimeType(values, MimeTypeUtils.parseMimeType(mimeType)); + } + + private Enum> forMimeType(List>> values, MimeType mimeType) { + for (Enum> candidate : values) { if (mimeType.isCompatibleWith(((Producible) candidate).getProducedMimeType())) { return candidate; } @@ -87,4 +94,20 @@ private static Enum> forType(List>> getValues(Class>> type) { + List>> values = Arrays.asList(type.getEnumConstants()); + Collections.reverse(values); + Assert.state(values.stream().filter(this::isDefault).count() <= 1, + "Multiple default values declared in " + type.getName()); + return values; + } + + private Enum> getDefaultValue(List>> values) { + return values.stream().filter(this::isDefault).findFirst().orElseGet(() -> values.get(0)); + } + + private boolean isDefault(Enum> value) { + return ((Producible) value).isDefault(); + } + } diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/ProducibleOperationArgumentResolverTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/ProducibleOperationArgumentResolverTests.java index a27350dd552d..a63dd31640a0 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/ProducibleOperationArgumentResolverTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/ProducibleOperationArgumentResolverTests.java @@ -22,12 +22,16 @@ import org.junit.jupiter.api.Test; +import org.springframework.util.MimeType; + import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; /** * Test for {@link ProducibleOperationArgumentResolver}. * * @author Andy Wilkinson + * @author Phillip Webb */ class ProducibleOperationArgumentResolverTests { @@ -40,11 +44,21 @@ void whenAcceptHeaderIsEmptyThenHighestOrdinalIsReturned() { assertThat(resolve(acceptHeader())).isEqualTo(ApiVersion.V3); } + @Test + void whenAcceptHeaderIsEmptyAndWithDefaultThenDefaultIsReturned() { + assertThat(resolve(acceptHeader(), WithDefault.class)).isEqualTo(WithDefault.TWO); + } + @Test void whenEverythingIsAcceptableThenHighestOrdinalIsReturned() { assertThat(resolve(acceptHeader("*/*"))).isEqualTo(ApiVersion.V3); } + @Test + void whenEverythingIsAcceptableWithDefaultThenDefaultIsReturned() { + assertThat(resolve(acceptHeader("*/*"), WithDefault.class)).isEqualTo(WithDefault.TWO); + } + @Test void whenNothingIsAcceptableThenNullIsReturned() { assertThat(resolve(acceptHeader("image/png"))).isEqualTo(null); @@ -68,13 +82,72 @@ void whenMultipleValuesAreAcceptableAsSingleHeaderThenHighestOrdinalIsReturned() assertThat(resolve(acceptHeader(V2_JSON + "," + V3_JSON))).isEqualTo(ApiVersion.V3); } + @Test + void withMultipleValuesOneOfWhichIsAllReturnsDefault() { + assertThat(resolve(acceptHeader("one/one", "*/*"), WithDefault.class)).isEqualTo(WithDefault.TWO); + } + + @Test + void whenMultipleDefaultsThrowsException() { + assertThatIllegalStateException().isThrownBy(() -> resolve(acceptHeader("one/one"), WithMultipleDefaults.class)) + .withMessageContaining("Multiple default values"); + } + private Supplier> acceptHeader(String... types) { List value = Arrays.asList(types); return () -> (value.isEmpty() ? null : value); } private ApiVersion resolve(Supplier> accepts) { - return new ProducibleOperationArgumentResolver(accepts).resolve(ApiVersion.class); + return resolve(accepts, ApiVersion.class); + } + + private T resolve(Supplier> accepts, Class type) { + return new ProducibleOperationArgumentResolver(accepts).resolve(type); + } + + enum WithDefault implements Producible { + + ONE("one/one"), + + TWO("two/two") { + + @Override + public boolean isDefault() { + return true; + } + + }, + + THREE("three/three"); + + private final MimeType mimeType; + + WithDefault(String mimeType) { + this.mimeType = MimeType.valueOf(mimeType); + } + + @Override + public MimeType getProducedMimeType() { + return this.mimeType; + } + + } + + enum WithMultipleDefaults implements Producible { + + ONE, TWO, THREE; + + @Override + public boolean isDefault() { + return true; + } + + @Override + public MimeType getProducedMimeType() { + return MimeType.valueOf("image/jpeg"); + } + } }