From 0bd415b5d9f2b0a59a1e2bd2692c0ced7080185a Mon Sep 17 00:00:00 2001 From: Scott Leberknight <174812+sleberknight@users.noreply.github.com> Date: Wed, 31 Jul 2024 14:48:07 -0400 Subject: [PATCH] Add methods to KiwiIO that can "close" any object (#1176) * Add: CloseableResource - a record that describes a resource/object that can be closed. * Add: List defaultCloseMethodNames - returns a list of default method names we'll try when closing an object. * Add: void closeObjectQuietly(Object object) - closes any object, including CloseableResource, using default close method names * Add: void closeObjectQuietly(String closeMethodName, Object object) - closes an object, excluding CloseableResource, using an explicit method name * Add: void closeObjectsQuietly(Object... objects) - closes one or more objects, including CloseableResource, using default close method names * Add: void closeObjectsQuietly(String closeMethodName, Object... objects) - closes one or more objects, excluding CloseableResource, using an explicit method name * Add: void closeResourceQuietly(CloseableResource closeableResource) - closes an object described by the CloseableResource * Fix: test involving XMLStreamWriter that should have tested a "clean" close (no exception thrown) * Enhance tests using mocks by adding verifications Closes #1162 Closes #1177 --- src/main/java/org/kiwiproject/io/KiwiIO.java | 239 +++++++++++- .../java/org/kiwiproject/io/KiwiIOTest.java | 366 +++++++++++++++++- 2 files changed, 591 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/kiwiproject/io/KiwiIO.java b/src/main/java/org/kiwiproject/io/KiwiIO.java index ec804f3b..62bf9ed7 100644 --- a/src/main/java/org/kiwiproject/io/KiwiIO.java +++ b/src/main/java/org/kiwiproject/io/KiwiIO.java @@ -1,12 +1,20 @@ package org.kiwiproject.io; +import static com.google.common.base.Preconditions.checkArgument; +import static java.lang.invoke.MethodType.methodType; import static java.util.Objects.isNull; import static java.util.Objects.nonNull; import static java.util.stream.Collectors.joining; +import static org.kiwiproject.base.KiwiPreconditions.checkArgumentContainsOnlyNotBlank; +import static org.kiwiproject.base.KiwiPreconditions.checkArgumentNotBlank; +import static org.kiwiproject.base.KiwiPreconditions.checkArgumentNotEmpty; +import static org.kiwiproject.base.KiwiPreconditions.checkArgumentNotInstanceOf; import static org.kiwiproject.base.KiwiPreconditions.checkArgumentNotNull; +import static org.kiwiproject.base.KiwiPreconditions.requireNotBlank; import lombok.experimental.UtilityClass; import lombok.extern.slf4j.Slf4j; +import org.checkerframework.checker.nullness.qual.Nullable; import javax.xml.stream.XMLStreamReader; import javax.xml.stream.XMLStreamWriter; @@ -18,10 +26,12 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.io.UncheckedIOException; +import java.lang.invoke.MethodHandles; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.List; +import java.util.Objects; import java.util.stream.Stream; /** @@ -39,6 +49,9 @@ @Slf4j public class KiwiIO { + private static final List DEFAULT_CLOSE_METHOD_NAMES = + List.of("close", "stop", "shutdown", "shutdownNow"); + /** * Closes a Closeable unconditionally. *

@@ -71,7 +84,7 @@ public class KiwiIO { * } * * - * @param closeable the objects to close, may be null or already closed + * @param closeable the object to close, may be null or already closed * @implNote Copied from Apache Commons I/O's IOUtils once it became deprecated with the message "Please use * the try-with-resources statement or handle suppressed exceptions manually." * @see Throwable#addSuppressed(java.lang.Throwable) @@ -82,7 +95,7 @@ public static void closeQuietly(final Closeable closeable) { closeable.close(); } } catch (final IOException ioe) { - logCloseException(closeable.getClass(), ioe); + logCloseError(closeable.getClass(), ioe); } } @@ -153,7 +166,7 @@ public static void closeQuietly(XMLStreamReader xmlStreamReader) { try { xmlStreamReader.close(); } catch (Exception e) { - logCloseException(XMLStreamReader.class, e); + logCloseError(XMLStreamReader.class, e); } } } @@ -171,16 +184,226 @@ public static void closeQuietly(XMLStreamWriter xmlStreamWriter) { try { xmlStreamWriter.close(); } catch (Exception e) { - logCloseException(XMLStreamWriter.class, e); + logCloseError(XMLStreamWriter.class, e); } } } - private static void logCloseException(Class typeOfObject, Exception ex) { - String typeSimpleName = typeOfObject.getSimpleName(); + + /** + * Represents a resource that can be closed using a "close" method. + *

+ * Allows multiple close method names to be specified, which can be useful + * in situations where you want to close several resources that have + * different "close" methods. For example, any {@link AutoCloseable} + * contains a "close" method while any {@link java.util.concurrent.ExecutorService} + * has both "shutdown" and "shutdownNow" methods. + *

+ * If you only need a single clsoe method name, or the default close + * method names, you can use one of the secondary constructors. + * + * @param object the resource that can be closed + * @param closeMethodNames a non-null, non-empty list of close method names + */ + public record CloseableResource(@Nullable Object object, List closeMethodNames) { + public CloseableResource { + checkArgumentContainsOnlyNotBlank(closeMethodNames, + "closeMethodNames must not be null or empty, or contain any blanks"); + } + + /** + * Create a new instance with a default set of close method names. + * + * @param object the resource that can be closed + * @see KiwiIO#defaultCloseMethodNames() + */ + public CloseableResource(@Nullable Object object) { + this(object, DEFAULT_CLOSE_METHOD_NAMES); + } + + /** + * Create a new instance with a single close method name. + * + * @param object the resource that can be closed + * @param closeMethodName the single close method name + */ + public CloseableResource(@Nullable Object object, String closeMethodName) { + this( + object, + List.of(requireNotBlank(closeMethodName, "closeMethodName must not be blank")) + ); + } + } + + /** + * Return the default method names used when closing objects using + * any of the methods to close generic {@link Object}. + *

+ * These method names are tried in order when attempting to close + * an Object when no explicit close method name is provided. + *

+ * The default names are + * + * @return the default close method names + */ + public static List defaultCloseMethodNames() { + return DEFAULT_CLOSE_METHOD_NAMES; + } + + /** + * Closes an object unconditionally. This method ignores null objects and exceptions. + *

+ * The object may be a {@link CloseableResource}. + *

+ * Uses the default close method names. + * + * @param object the object to close, may be null or already closed + * @see #defaultCloseMethodNames() + */ + public static void closeObjectQuietly(Object object) { + if (isNull(object)) { + return; + } + + var closeableResource = asCloseableResource(object); + closeResourceQuietly(closeableResource); + } + + /** + * Closes an object unconditionally. This method ignores null objects and exceptions. + *

+ * The object may not be a {@link CloseableResource}, since it could contain a different + * close method name. + * + * @param closeMethodName the name of the close method + * @param object the object to close, may be null or already closed + * @throws IllegalArgumentException if closeMethodName is blank or object is a CloseableResource + */ + public static void closeObjectQuietly(String closeMethodName, Object object) { + checkArgumentNotBlank(closeMethodName, "closeMethodName must not be blank"); + checkArgumentNotInstanceOf(object, CloseableResource.class, + "object must not be a CloseableResource"); + closeResourceQuietly(new CloseableResource(object, List.of(closeMethodName))); + } + + /** + * Closes one or more objects unconditionally. This method ignores null objects and exceptions. + *

+ * The objects may contain {@link CloseableResource} and/or other closeable objects. + *

+ * Uses the default close method names. + * + * @param objects the objects to close, may be null or already closed + * @see #defaultCloseMethodNames() + */ + public static void closeObjectsQuietly(Object... objects) { + if (isNull(objects)) { + return; + } + + Arrays.stream(objects) + .filter(Objects::nonNull) + .map(KiwiIO::asCloseableResource) + .forEach(KiwiIO::closeResourceQuietly); + } + + private static CloseableResource asCloseableResource(Object object) { + return (object instanceof CloseableResource closeableResource) ? + closeableResource : new CloseableResource(object, DEFAULT_CLOSE_METHOD_NAMES); + } + + /** + * Closes one or more objects unconditionally. This method ignores null objects and exceptions. + *

+ * The objects should not contain any {@link CloseableResource} instances. The reason is that + * those could specify a different close method name. + * + * @param closeMethodName the name of the close method + * @param objects the objects to close, may be null or already closed + * @throws IllegalArgumentException of objects contains any CloseableResource instances + */ + public static void closeObjectsQuietly(String closeMethodName, Object... objects) { + if (isNull(objects)) { + return; + } + + checkDoesNotContainAnyCloseableResources(closeMethodName, objects); + + Arrays.stream(objects) + .filter(Objects::nonNull) + .map(object -> new CloseableResource(object, List.of(closeMethodName))) + .forEach(KiwiIO::closeResourceQuietly); + } + + private static void checkDoesNotContainAnyCloseableResources(String closeMethodName, Object... objects) { + for (var object : objects) { + checkIsNotCloseableResource(closeMethodName, object); + } + } + + private static void checkIsNotCloseableResource(String closeMethodName, Object object) { + checkArgument( + isNotCloseableResource(object), + "objects should not contain any instances of CloseableResource when a single closeMethodName (%s) is specified", + closeMethodName); + } + + private static boolean isNotCloseableResource(Object object) { + return !(object instanceof CloseableResource); + } + + /** + * Closes a resource unconditionally. This method ignores null objects and exceptions. + *

+ * The object inside the resource may be null or already closed. The resource must + * contain at least one close method name. + * + * @param closeableResource the resource to close, must not be null + * @throws IllegalArgumentException if the closeableResource is null or has no close method names + */ + public static void closeResourceQuietly(CloseableResource closeableResource) { + checkArgumentNotNull(closeableResource, "closeableResource must not be null"); + + var closeMethodNames = closeableResource.closeMethodNames(); + checkArgumentNotEmpty(closeMethodNames, "closeMethodNames must not be empty"); + + var object = closeableResource.object(); + if (isNull(object)) { + return; + } + + var objectType = object.getClass(); + var typeName = objectType.getName(); + + closeMethodNames.stream() + .map(methodName -> tryClose(object, objectType, typeName, methodName)) + .filter(CloseResult::succeeded) + .findFirst() + .ifPresentOrElse( + successResult -> LOG.trace("Successfully closed a {} using {}", typeName, successResult.methodName()), + () -> LOG.warn("All attempts to close a {} failed. Tried using methods: {}", typeName, closeMethodNames)); + } + + private CloseResult tryClose(Object object, Class objectType, String typeName, String closeMethodName) { + try { + LOG.trace("Attempting to close a {} using {}", typeName, closeMethodName); + var methodHandle = MethodHandles.lookup() + .findVirtual(objectType, closeMethodName, methodType(Void.TYPE)); + methodHandle.invoke(object); + return new CloseResult(true, closeMethodName, null); + } catch (Throwable error) { + LOG.trace("Unable to close a {} using {}", typeName, closeMethodName, error); + return new CloseResult(false, closeMethodName, error); + } + } + + private record CloseResult(boolean succeeded, String methodName, Throwable error) { + } + + private static void logCloseError(Class typeOfObject, Throwable error) { LOG.warn("Unexpected error while attempting to close {} quietly (use DEBUG-level for stack trace): {}", - typeSimpleName, ex.getMessage()); - LOG.debug("Error closing {} instance", typeSimpleName, ex); + typeOfObject.getSimpleName(), error.getMessage()); + LOG.debug("Error closing {} instance", typeOfObject.getName(), error); } /** diff --git a/src/test/java/org/kiwiproject/io/KiwiIOTest.java b/src/test/java/org/kiwiproject/io/KiwiIOTest.java index ea3595f2..6a0f45c3 100644 --- a/src/test/java/org/kiwiproject/io/KiwiIOTest.java +++ b/src/test/java/org/kiwiproject/io/KiwiIOTest.java @@ -5,9 +5,12 @@ import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import org.junit.jupiter.api.BeforeEach; @@ -15,10 +18,11 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; import org.junit.jupiter.params.provider.ValueSource; +import org.kiwiproject.io.KiwiIO.CloseableResource; import javax.xml.stream.XMLInputFactory; -import javax.xml.stream.XMLOutputFactory; import javax.xml.stream.XMLStreamException; import javax.xml.stream.XMLStreamReader; import javax.xml.stream.XMLStreamWriter; @@ -42,6 +46,7 @@ import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.Arrays; +import java.util.List; @DisplayName("KiwiIO") class KiwiIOTest { @@ -67,6 +72,7 @@ void shouldClose_Reader_WhenThrowsOnClose() throws IOException { var reader = mock(Reader.class); doThrow(new IOException("I cannot read")).when(reader).close(); assertThatCode(() -> KiwiIO.closeQuietly(reader)).doesNotThrowAnyException(); + verify(reader).close(); } @SuppressWarnings("ConstantValue") @@ -87,6 +93,7 @@ void shouldClose_Writer_WhenThrowsOnClose() throws IOException { var writer = mock(Writer.class); doThrow(new IOException("I cannot write")).when(writer).close(); assertThatCode(() -> KiwiIO.closeQuietly(writer)).doesNotThrowAnyException(); + verify(writer).close(); } @SuppressWarnings("ConstantValue") @@ -107,6 +114,7 @@ void shouldClose_InputStream_WhenThrowsOnClose() throws IOException { var stream = mock(InputStream.class); doThrow(new IOException("I cannot read")).when(stream).close(); assertThatCode(() -> KiwiIO.closeQuietly(stream)).doesNotThrowAnyException(); + verify(stream).close(); } @SuppressWarnings("ConstantValue") @@ -127,6 +135,7 @@ void shouldClose_OutputStream_WhenThrowsOnClose() throws IOException { var stream = mock(OutputStream.class); doThrow(new IOException("I cannot stream")).when(stream).close(); assertThatCode(() -> KiwiIO.closeQuietly(stream)).doesNotThrowAnyException(); + verify(stream).close(); } @SuppressWarnings("ConstantValue") @@ -136,6 +145,7 @@ void shouldClose_NullSocket() { assertThatCode(() -> KiwiIO.closeQuietly(socket)).doesNotThrowAnyException(); } + @SuppressWarnings("resource") @Test void shouldClose_Socket() { var socket = new Socket(); @@ -147,6 +157,7 @@ void shouldClose_Socket_WhenThrowsOnClose() throws IOException { var socket = mock(Socket.class); doThrow(new IOException("I cannot read")).when(socket).close(); assertThatCode(() -> KiwiIO.closeQuietly(socket)).doesNotThrowAnyException(); + verify(socket).close(); } @SuppressWarnings("ConstantValue") @@ -156,6 +167,7 @@ void shouldClose_NullSelector() { assertThatCode(() -> KiwiIO.closeQuietly(selector)).doesNotThrowAnyException(); } + @SuppressWarnings("resource") @Test void shouldClose_Selector() throws IOException { var selector = Selector.open(); @@ -164,9 +176,10 @@ void shouldClose_Selector() throws IOException { @Test void shouldClose_Selector_WhenThrowsOnClose() throws IOException { - var socket = mock(Selector.class); - doThrow(new IOException("I cannot select")).when(socket).close(); - assertThatCode(() -> KiwiIO.closeQuietly(socket)).doesNotThrowAnyException(); + var selector = mock(Selector.class); + doThrow(new IOException("I cannot select")).when(selector).close(); + assertThatCode(() -> KiwiIO.closeQuietly(selector)).doesNotThrowAnyException(); + verify(selector).close(); } @SuppressWarnings("ConstantValue") @@ -176,6 +189,7 @@ void shouldClose_NullServerSocket() { assertThatCode(() -> KiwiIO.closeQuietly(socket)).doesNotThrowAnyException(); } + @SuppressWarnings("resource") @Test void shouldClose_ServerSocket() throws IOException { var serverSocket = new ServerSocket(); @@ -187,6 +201,7 @@ void shouldClose_ServerSocket_WhenThrowsOnClose() throws IOException { var serverSocket = mock(ServerSocket.class); doThrow(new IOException("I cannot read")).when(serverSocket).close(); assertThatCode(() -> KiwiIO.closeQuietly(serverSocket)).doesNotThrowAnyException(); + verify(serverSocket).close(); } @SuppressWarnings("ConstantValue") @@ -228,6 +243,10 @@ void shouldClose_Closeables_WhenThrowOnClose() throws IOException { doThrow(new IOException("I cannot read")).when(serverSocket).close(); assertThatCode(() -> KiwiIO.closeQuietly(socket, selector, serverSocket)).doesNotThrowAnyException(); + + verify(socket).close(); + verify(selector).close(); + verify(serverSocket).close(); } @SuppressWarnings("ConstantValue") @@ -248,6 +267,7 @@ void shouldClose_XMLStreamReader_WhenThrowsOnClose() throws XMLStreamException { var xmlStreamReader = mock(XMLStreamReader.class); doThrow(new XMLStreamException("I cannot stream XML")).when(xmlStreamReader).close(); assertThatCode(() -> KiwiIO.closeQuietly(xmlStreamReader)).doesNotThrowAnyException(); + verify(xmlStreamReader).close(); } @SuppressWarnings("ConstantValue") @@ -259,8 +279,10 @@ void shouldClose_NullXMLStreamWriter() { @Test void shouldClose_XMLStreamWriter() throws XMLStreamException { - var xmlStreamWriter = XMLOutputFactory.newInstance().createXMLStreamWriter(new StringWriter()); + var xmlStreamWriter = mock(XMLStreamWriter.class); + doNothing().when(xmlStreamWriter).close(); assertThatCode(() -> KiwiIO.closeQuietly(xmlStreamWriter)).doesNotThrowAnyException(); + verify(xmlStreamWriter).close(); } @Test @@ -268,6 +290,337 @@ void shouldClose_XMLStreamWriter_WhenThrowsOnClose() throws XMLStreamException { var xmlStreamWriter = mock(XMLStreamWriter.class); doThrow(new XMLStreamException("I cannot stream XML")).when(xmlStreamWriter).close(); assertThatCode(() -> KiwiIO.closeQuietly(xmlStreamWriter)).doesNotThrowAnyException(); + verify(xmlStreamWriter).close(); + } + } + + @Nested + class CloseObjectQuietly { + + @Test + void shouldReturnDefaultCloseMethodNames() { + assertThat(KiwiIO.defaultCloseMethodNames()) + .describedAs("If this fails, be sure to also updated the javadoc when fixing this test!") + .containsExactly("close", "stop", "shutdown", "shutdownNow"); + } + + @ParameterizedTest + @NullAndEmptySource + void shouldRequireAtLeastOneCloseMethodName_ToCreateCloseableResource(List closeMethodNames) { + assertThatIllegalArgumentException() + .isThrownBy(() -> new CloseableResource(new Closer(), closeMethodNames)); + } + + @Test + void shouldCreateCloseableResource_WithDefaultCloseMethodNames() { + var closeableResource = new CloseableResource(new Stopper()); + + assertThat(closeableResource.closeMethodNames()) + .containsExactly("close", "stop", "shutdown", "shutdownNow"); + } + + @Test + void shouldCreateCloseableResource_WithSingleCloseMethodName() { + var closeableResource = new CloseableResource(new Halter(), "halt"); + + assertThat(closeableResource.closeMethodNames()).containsExactly("halt"); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\t", "\n"}) + void shouldNotAllow_BlankCloseMethodNames_WhenCreatingCloseableResource(String value) { + assertThatIllegalArgumentException() + .isThrownBy(() -> new CloseableResource(null, value)) + .withMessage("closeMethodName must not be blank"); + } + + @Test + void shouldNotAllow_BlankCloseMethodNames_WhenCreatingCloseableResource() { + var closeMethodNames = List.of("close", "stop", "", "terminate"); + assertThatIllegalArgumentException() + .isThrownBy(() -> new CloseableResource(null, closeMethodNames)) + .withMessage("closeMethodNames must not be null or empty, or contain any blanks"); + } + + @Test + void shouldIgnoreNullArguments() { + assertAll( + () -> assertThatCode(() -> KiwiIO.closeObjectQuietly(null)) + .doesNotThrowAnyException(), + + () -> assertThatCode(() -> KiwiIO.closeObjectsQuietly((Object[]) null)) + .doesNotThrowAnyException(), + + () -> assertThatCode(() -> KiwiIO.closeObjectQuietly("halt", null)) + .doesNotThrowAnyException(), + + () -> assertThatCode(() -> KiwiIO.closeObjectsQuietly("halt", (Object[]) null)) + .doesNotThrowAnyException() + ); + } + + @Test + void shouldRequireCloseMethodName() { + assertAll( + () -> assertThatIllegalArgumentException() + .isThrownBy(() -> KiwiIO.closeObjectsQuietly("", new Closer(), new Closer())), + + () -> assertThatIllegalArgumentException() + .isThrownBy(() -> KiwiIO.closeObjectQuietly("", new Stopper())) + ); + } + + @Test + void shouldCloseObjectWithCloseMethod() { + var closer = new Closer(); + KiwiIO.closeObjectQuietly(closer); + + assertThat(closer.closeCalled).isTrue(); + } + + @Test + void shouldCloseObjectWithStopMethod() { + var stopper = new Stopper(); + KiwiIO.closeObjectQuietly(stopper); + + assertThat(stopper.stopCalled).isTrue(); + } + + @Test + void shouldCloseObjectWithShutdownMethod() { + var shutdown = new Shutdown(); + KiwiIO.closeObjectQuietly(shutdown); + + assertThat(shutdown.shutdownCalled).isTrue(); + } + + @Test + void shouldCloseObjectWithShutdownNowMethod() { + var shutdownNow = new ShutdownNow(); + KiwiIO.closeObjectQuietly(shutdownNow); + + assertThat(shutdownNow.shutdownNowCalled).isTrue(); + } + + @Test + void shouldClose_CloseableResource() { + var halter = new Halter(); + var closeableResource = new CloseableResource(halter, List.of("halt")); + + KiwiIO.closeObjectQuietly(closeableResource); + + assertThat(halter.haltCalled).isTrue(); + } + + @Test + void shouldIgnore_ExceptionsOnClose_ForDefaultCloseMethods() { + var stopper = new ThrowingStopper(); + + assertThatCode(() -> KiwiIO.closeObjectQuietly(stopper)).doesNotThrowAnyException(); + + assertThat(stopper.stopCalled).isTrue(); + } + + @Test + void shouldClose_UsingCustomCloseMethod() { + var terminator = new Terminator(); + + assertThatCode(() -> KiwiIO.closeObjectQuietly("terminate", terminator)) + .doesNotThrowAnyException(); + + assertThat(terminator.terminated).isTrue(); + } + + @Test + void shouldIgnore_ExceptionsOnClose_ForCustomCloseMethods() { + var canceller = new ThrowingCanceller(); + + assertThatCode(() -> KiwiIO.closeObjectQuietly("cancel", canceller)) + .doesNotThrowAnyException(); + + assertThat(canceller.cancelCalled).isTrue(); + } + + @Test + void shouldNotPermit_CloseableResource_WithCustomCloseMethod() { + var halter = new Halter(); + var resource = new CloseableResource(halter, "halt"); + + assertThatIllegalArgumentException() + .isThrownBy(() -> KiwiIO.closeObjectQuietly("stop", resource)) + .withMessage("object must not be a CloseableResource"); + } + + @Test + void shouldCloseManyObjects_WithSameCloseMethodName() { + var halter1 = new Halter(); + var halter2 = new Halter(); + var halter3 = new Halter(); + + KiwiIO.closeObjectsQuietly("halt", halter1, halter2, halter3); + assertAll( + () -> assertThat(halter1.haltCalled).isTrue(), + () -> assertThat(halter2.haltCalled).isTrue(), + () -> assertThat(halter3.haltCalled).isTrue() + ); + } + + @Test + void shouldNotAllow_CloseableResource_WhenGivenExplicit_CloseMethodName() { + var halter1 = new Halter(); + var halter2 = new Halter(); + var terminator = new Terminator(); + var terminatorResource = new CloseableResource(terminator, "terminate"); + + var closeMethodName = "halt"; + assertThatIllegalArgumentException() + .isThrownBy(() -> KiwiIO.closeObjectsQuietly(closeMethodName, halter1, halter2, terminatorResource)) + .withMessage("objects should not contain any instances of CloseableResource when a single closeMethodName (%s) is specified", closeMethodName); + } + + @Test + void shouldCloseManyObjects_WithDifferingCloseMethodName() { + var closer = new Closer(); + var stopper = new Stopper(); + var shutdown = new Shutdown(); + var shutdownNow = new ShutdownNow(); + + KiwiIO.closeObjectsQuietly(closer, stopper, shutdown, shutdownNow); + + assertAll( + () -> assertThat(closer.closeCalled).isTrue(), + () -> assertThat(stopper.stopCalled).isTrue(), + () -> assertThat(shutdown.shutdownCalled).isTrue(), + () -> assertThat(shutdownNow.shutdownNowCalled).isTrue() + ); + } + + @Test + void shouldClose_MixOfObjects_AndCloseableResources() { + var halter = new Halter(); + var halterResource = new CloseableResource(halter, "halt"); + + var terminator = new Terminator(); + var terminatorResource = new CloseableResource(terminator, "terminate"); + + var stopper = new Stopper(); + + KiwiIO.closeObjectsQuietly(halterResource, stopper, terminatorResource); + + assertAll( + () -> assertThat(halter.haltCalled).isTrue(), + () -> assertThat(stopper.stopCalled).isTrue(), + () -> assertThat(terminator.terminated).isTrue() + ); + } + + @Test + void shouldRequire_NonNull_CloseableResource() { + assertThatIllegalArgumentException() + .isThrownBy(() -> KiwiIO.closeResourceQuietly(null)) + .withMessage("closeableResource must not be null"); + } + + @Test + void shouldClose_CloseableResources() { + var closer = new Closer(); + var closerResource = new CloseableResource(closer, "close"); + + var stopper = new Stopper(); + var stopperResource = new CloseableResource(stopper, "stop"); + + var halter = new Halter(); + var halterResource = new CloseableResource(halter, "halt"); + + var terminator = new Terminator(); + var terminatorResource = new CloseableResource(terminator, "terminate"); + + KiwiIO.closeResourceQuietly(closerResource); + KiwiIO.closeResourceQuietly(stopperResource); + KiwiIO.closeResourceQuietly(halterResource); + KiwiIO.closeResourceQuietly(terminatorResource); + + assertAll( + () -> assertThat(closer.closeCalled).isTrue(), + () -> assertThat(stopper.stopCalled).isTrue(), + () -> assertThat(halter.haltCalled).isTrue(), + () -> assertThat(terminator.terminated).isTrue() + ); + } + + static class Closer { + boolean closeCalled; + + @SuppressWarnings("unused") + void close() { + closeCalled = true; + } + } + + static class Stopper { + boolean stopCalled; + + @SuppressWarnings("unused") + void stop() { + stopCalled = true; + } + } + + static class Shutdown { + boolean shutdownCalled; + + @SuppressWarnings("unused") + void shutdown() { + shutdownCalled = true; + } + } + + static class ShutdownNow { + boolean shutdownNowCalled; + + @SuppressWarnings("unused") + void shutdownNow() { + shutdownNowCalled = true; + } + } + + static class Halter { + boolean haltCalled; + + @SuppressWarnings("unused") + void halt() { + haltCalled = true; + } + } + + static class Terminator { + boolean terminated; + + @SuppressWarnings("unused") + void terminate() { + terminated = true; + } + } + + static class ThrowingStopper { + boolean stopCalled; + + @SuppressWarnings("unused") + void stop() throws Exception { + stopCalled = true; + throw new Exception("stop failed!"); + } + } + + static class ThrowingCanceller { + boolean cancelCalled; + + @SuppressWarnings("unused") + void cancel() throws Exception { + cancelCalled = true; + throw new Exception("cancel failed!"); + } } } @@ -345,7 +698,7 @@ void shouldEncodeStringsUsingUTF8() { } @Nested - class NewByteArrayInputStreamWtihCharset { + class NewByteArrayInputStreamWithCharset { @Test void shouldRequireNonNullInputString() { @@ -508,6 +861,7 @@ void shouldReadInputStream_UsingExplicitCharset() { .isEqualTo(String.join(System.lineSeparator(), LINE_1, LINE_2, LINE_3, LINE_4)); } + @SuppressWarnings("resource") @Test void shouldThrowUncheckedIOException_WhenIOExceptionIsThrown() throws IOException { var inputStream = mock(InputStream.class);