CloseAutoCloseableTest.java

package tools.jackson.databind.ser;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.StringWriter;

import org.junit.jupiter.api.Test;

import tools.jackson.core.JacksonException;
import tools.jackson.core.JsonGenerator;
import tools.jackson.core.exc.JacksonIOException;

import tools.jackson.databind.DatabindException;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.SequenceWriter;
import tools.jackson.databind.SerializationFeature;
import tools.jackson.databind.testutil.DatabindTestUtil;

import static org.junit.jupiter.api.Assertions.*;

/**
 * Tests for {@link SerializationFeature#CLOSE_CLOSEABLE} handling of values that
 * implement {@link AutoCloseable} but not {@link java.io.Closeable}: the feature
 * checks for the former, so all code paths need to handle it.
 */
public class CloseAutoCloseableTest extends DatabindTestUtil
{
    static class AutoCloseableBean implements AutoCloseable {
        public int a = 3;

        public boolean wasClosed = false;

        // Note: `AutoCloseable.close()` may throw checked `Exception`, unlike
        // `Closeable.close()` which is limited to `IOException`
        @Override
        public void close() throws Exception {
            wasClosed = true;
        }
    }

    static class FailingAutoCloseableBean implements AutoCloseable {
        public int a = 3;

        // Checked, non-`IOException` failure: only possible for `AutoCloseable`
        @Override
        public void close() throws Exception {
            throw new Exception("Fail on close()");
        }
    }

    private final ObjectMapper MAPPER = jsonMapperBuilder()
            .enable(SerializationFeature.CLOSE_CLOSEABLE)
            .build();

    @Test
    public void mapperWriteValueToGenerator() throws Exception
    {
        AutoCloseableBean bean = new AutoCloseableBean();
        StringWriter sw = new StringWriter();
        try (JsonGenerator g = MAPPER.createGenerator(sw)) {
            MAPPER.writeValue(g, bean);
        }
        assertEquals("""
                {"a":3,"wasClosed":false}""", sw.toString());
        assertTrue(bean.wasClosed);
    }

    // Same case, via `ObjectWriter`: has always worked, guard against regression
    @Test
    public void writerWriteValueToGenerator() throws Exception
    {
        AutoCloseableBean bean = new AutoCloseableBean();
        StringWriter sw = new StringWriter();
        try (JsonGenerator g = MAPPER.createGenerator(sw)) {
            MAPPER.writer().writeValue(g, bean);
        }
        assertEquals("""
                {"a":3,"wasClosed":false}""", sw.toString());
        assertTrue(bean.wasClosed);
    }

    // Failure to `close()` the value must not close (or otherwise mess with) the
    // caller-owned Generator, and must be reported as `JacksonException`
    @Test
    public void mapperCloseFailureWithGenerator() throws Exception
    {
        StringWriter sw = new StringWriter();
        try (JsonGenerator g = MAPPER.createGenerator(sw)) {
            DatabindException e = assertThrows(DatabindException.class,
                    () -> MAPPER.writeValue(g, new FailingAutoCloseableBean()));
            verifyException(e, "Failed to close value of type");
            assertFalse(g.isClosed());
            g.writeString("after");
        }
        assertEquals("""
                {"a":3} "after\"""", sw.toString());
    }

    @Test
    public void writerCloseFailureWithGenerator() throws Exception
    {
        StringWriter sw = new StringWriter();
        try (JsonGenerator g = MAPPER.createGenerator(sw)) {
            DatabindException e = assertThrows(DatabindException.class,
                    () -> MAPPER.writer().writeValue(g, new FailingAutoCloseableBean()));
            verifyException(e, "Failed to close value of type");
            assertFalse(g.isClosed());
            g.writeString("after");
        }
        assertEquals("""
                {"a":3} "after\"""", sw.toString());
    }

    @Test
    public void mapperWriteValueToStream() throws Exception
    {
        AutoCloseableBean bean = new AutoCloseableBean();
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        MAPPER.writeValue(out, bean);
        assertEquals("""
                {"a":3,"wasClosed":false}""", out.toString("UTF-8"));
        assertTrue(bean.wasClosed);
    }

    @Test
    public void mapperWriteValueAsString() throws Exception
    {
        AutoCloseableBean bean = new AutoCloseableBean();
        assertEquals("""
                {"a":3,"wasClosed":false}""", MAPPER.writeValueAsString(bean));
        assertTrue(bean.wasClosed);
    }

    // Mapper-owned Generator paths must not leak `close()` failure as plain
    // `RuntimeException` either
    @Test
    public void mapperOwnedGeneratorCloseFailure() throws Exception
    {
        DatabindException e = assertThrows(DatabindException.class,
                () -> MAPPER.writeValueAsString(new FailingAutoCloseableBean()));
        verifyException(e, "Failed to close value of type");

        e = assertThrows(DatabindException.class,
                () -> MAPPER.writer().writeValueAsString(new FailingAutoCloseableBean()));
        verifyException(e, "Failed to close value of type");
    }

    // [databind#6197]: `SequenceWriter` also needs to handle `AutoCloseable`,
    // not just `Closeable` (before fix: value simply never closed)
    @Test
    public void sequenceWriterWriteValue() throws Exception
    {
        AutoCloseableBean bean = new AutoCloseableBean();
        StringWriter sw = new StringWriter();
        try (SequenceWriter seq = MAPPER.writer().writeValues(sw)) {
            seq.write(bean);
        }
        assertEquals("""
                {"a":3,"wasClosed":false}""", sw.toString());
        assertTrue(bean.wasClosed);
    }

    @Test
    public void sequenceWriterWriteValueWithType() throws Exception
    {
        AutoCloseableBean bean = new AutoCloseableBean();
        StringWriter sw = new StringWriter();
        try (SequenceWriter seq = MAPPER.writer().writeValues(sw)) {
            seq.write(bean, MAPPER.constructType(AutoCloseableBean.class));
        }
        assertEquals("""
                {"a":3,"wasClosed":false}""", sw.toString());
        assertTrue(bean.wasClosed);
    }

    @Test
    public void sequenceWriterCloseFailure() throws Exception
    {
        StringWriter sw = new StringWriter();
        try (SequenceWriter seq = MAPPER.writer().writeValues(sw)) {
            DatabindException e = assertThrows(DatabindException.class,
                    () -> seq.write(new FailingAutoCloseableBean()));
            verifyException(e, "Failed to close value of type");
        }
    }

    // If both serialization and the recovery `close()` fail, the original failure
    // must survive, with close failure merely added as "suppressed"
    static class FailOnSerializeAndCloseBean implements AutoCloseable {
        public int getA() {
            throw new IllegalArgumentException("Fail on serialize");
        }

        @Override
        public void close() {
            throw new IllegalStateException("Fail on close");
        }
    }

    @Test
    public void sequenceWriterSerializationFailureKeepsCloseFailure() throws Exception
    {
        SequenceWriter seq = MAPPER.writer().writeValues(new StringWriter());
        Exception e = assertThrows(Exception.class,
                () -> seq.write(new FailOnSerializeAndCloseBean()));
        verifyException(e, "Fail on serialize");
        Throwable[] suppressed = e.getSuppressed();
        assertEquals(1, suppressed.length);
        verifyException(suppressed[0], "Fail on close");

        // and same for the `JavaType`-taking variant
        SequenceWriter seq2 = MAPPER.writer().writeValues(new StringWriter());
        e = assertThrows(Exception.class,
                () -> seq2.write(new FailOnSerializeAndCloseBean(),
                        MAPPER.constructType(FailOnSerializeAndCloseBean.class)));
        verifyException(e, "Fail on serialize");
        suppressed = e.getSuppressed();
        assertEquals(1, suppressed.length);
        verifyException(suppressed[0], "Fail on close");
    }

    // `JacksonException` from `close()` must be passed through as-is, not re-wrapped
    static class JacksonFailOnCloseBean implements AutoCloseable {
        public final static JacksonException FAILURE
            = JacksonIOException.construct(new IOException("Fail on close()"));

        public int a = 3;

        @Override
        public void close() {
            throw FAILURE;
        }
    }

    @Test
    public void jacksonExceptionFromCloseNotRewrapped() throws Exception
    {
        StringWriter sw = new StringWriter();
        try (JsonGenerator g = MAPPER.createGenerator(sw)) {
            assertSame(JacksonFailOnCloseBean.FAILURE,
                    assertThrows(JacksonException.class,
                            () -> MAPPER.writeValue(g, new JacksonFailOnCloseBean())));
            assertSame(JacksonFailOnCloseBean.FAILURE,
                    assertThrows(JacksonException.class,
                            () -> MAPPER.writer().writeValue(g, new JacksonFailOnCloseBean())));
        }
        assertSame(JacksonFailOnCloseBean.FAILURE,
                assertThrows(JacksonException.class,
                        () -> MAPPER.writeValueAsString(new JacksonFailOnCloseBean())));
        assertSame(JacksonFailOnCloseBean.FAILURE,
                assertThrows(JacksonException.class,
                        () -> MAPPER.writer().writeValueAsString(new JacksonFailOnCloseBean())));
        try (SequenceWriter seq = MAPPER.writer().writeValues(new StringWriter())) {
            assertSame(JacksonFailOnCloseBean.FAILURE,
                    assertThrows(JacksonException.class,
                            () -> seq.write(new JacksonFailOnCloseBean())));
            assertSame(JacksonFailOnCloseBean.FAILURE,
                    assertThrows(JacksonException.class,
                            () -> seq.write(new JacksonFailOnCloseBean(),
                                    MAPPER.constructType(JacksonFailOnCloseBean.class))));
        }
    }
}