diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/origin/Origin.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/origin/Origin.java index cf7307beee..559a2b1106 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/origin/Origin.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/origin/Origin.java @@ -17,6 +17,11 @@ package org.springframework.boot.origin; import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; /** * Interface that uniquely represents the origin of an item. For example, an item loaded @@ -30,12 +35,24 @@ import java.io.File; * @author Phillip Webb * @since 2.0.0 * @see OriginProvider + * @see TextResourceOrigin */ public interface Origin { + /** + * Return the parent origin for this instance if there is one. The parent origin + * provides the origin of the item that created this one. + * @return the parent origin or {@code null} + * @since 2.4.0 + * @see Origin#parentsFrom(Object) + */ + default Origin getParent() { + return null; + } + /** * Find the {@link Origin} that an object originated from. Checks if the source object - * is an {@link OriginProvider} and also searches exception stacks. + * is an {@link Origin} or {@link OriginProvider} and also searches exception stacks. * @param source the source object or {@code null} * @return an optional {@link Origin} */ @@ -53,4 +70,27 @@ public interface Origin { return origin; } + /** + * Find the parents of the {@link Origin} that an object originated from. Checks if + * the source object is an {@link Origin} or {@link OriginProvider} and also searches + * exception stacks. Provides a list of all parents up to root {@link Origin}, + * starting with the most immediate parent. + * @param source the source object or {@code null} + * @return a list of parents or an empty list if the source is {@code null}, has no + * origin, or no parent + */ + static List parentsFrom(Object source) { + Origin origin = from(source); + if (origin == null) { + return Collections.emptyList(); + } + Set parents = new LinkedHashSet<>(); + origin = origin.getParent(); + while (origin != null && !parents.contains(origin)) { + parents.add(origin); + origin = origin.getParent(); + } + return Collections.unmodifiableList(new ArrayList<>(parents)); + } + } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/origin/OriginTrackedResource.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/origin/OriginTrackedResource.java new file mode 100644 index 0000000000..bb49d7035b --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/origin/OriginTrackedResource.java @@ -0,0 +1,214 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.origin; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.net.URL; +import java.nio.channels.ReadableByteChannel; + +import org.springframework.core.io.Resource; +import org.springframework.core.io.WritableResource; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; + +/** + * Decorator that can be used to add {@link Origin} information to a {@link Resource} or + * {@link WritableResource}. + * + * @author Phillip Webb + * @since 2.4.0 + * @see #of(Resource, Origin) + * @see #of(WritableResource, Origin) + * @see OriginProvider + */ +public class OriginTrackedResource implements Resource, OriginProvider { + + private final Resource resource; + + private final Origin origin; + + /** + * Create a new {@link OriginTrackedResource} instance. + * @param resource the resource to track + * @param origin the origin of the resource + */ + OriginTrackedResource(Resource resource, Origin origin) { + Assert.notNull(resource, "Resource must not be null"); + this.resource = resource; + this.origin = origin; + } + + @Override + public InputStream getInputStream() throws IOException { + return getResource().getInputStream(); + } + + @Override + public boolean exists() { + return getResource().exists(); + } + + @Override + public boolean isReadable() { + return getResource().isReadable(); + } + + @Override + public boolean isOpen() { + return getResource().isOpen(); + } + + @Override + public boolean isFile() { + return getResource().isFile(); + } + + @Override + public URL getURL() throws IOException { + return getResource().getURL(); + } + + @Override + public URI getURI() throws IOException { + return getResource().getURI(); + } + + @Override + public File getFile() throws IOException { + return getResource().getFile(); + } + + @Override + public ReadableByteChannel readableChannel() throws IOException { + return getResource().readableChannel(); + } + + @Override + public long contentLength() throws IOException { + return getResource().contentLength(); + } + + @Override + public long lastModified() throws IOException { + return getResource().lastModified(); + } + + @Override + public Resource createRelative(String relativePath) throws IOException { + return getResource().createRelative(relativePath); + } + + @Override + public String getFilename() { + return getResource().getFilename(); + } + + @Override + public String getDescription() { + return getResource().getDescription(); + } + + public Resource getResource() { + return this.resource; + } + + @Override + public Origin getOrigin() { + return this.origin; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + OriginTrackedResource other = (OriginTrackedResource) obj; + return this.resource.equals(other) && ObjectUtils.nullSafeEquals(this.origin, other.origin); + } + + @Override + public int hashCode() { + final int prime = 31; + int result = this.resource.hashCode(); + result = prime * result + ObjectUtils.nullSafeHashCode(this.origin); + return result; + } + + @Override + public String toString() { + return this.resource.toString(); + } + + /** + * Return a new {@link OriginProvider origin tracked} version the given + * {@link WritableResource}. + * @param resource the tracked resource + * @param origin the origin of the resource + * @return a {@link OriginTrackedWritableResource} instance + */ + public static OriginTrackedWritableResource of(WritableResource resource, Origin origin) { + return (OriginTrackedWritableResource) of((Resource) resource, origin); + } + + /** + * Return a new {@link OriginProvider origin tracked} version the given + * {@link Resource}. + * @param resource the tracked resource + * @param origin the origin of the resource + * @return a {@link OriginTrackedResource} instance + */ + public static OriginTrackedResource of(Resource resource, Origin origin) { + if (resource instanceof WritableResource) { + return new OriginTrackedWritableResource((WritableResource) resource, origin); + } + return new OriginTrackedResource(resource, origin); + } + + /** + * Variant of {@link OriginTrackedResource} for {@link WritableResource} instances. + */ + public static class OriginTrackedWritableResource extends OriginTrackedResource implements WritableResource { + + /** + * Create a new {@link OriginTrackedWritableResource} instance. + * @param resource the resource to track + * @param origin the origin of the resource + */ + OriginTrackedWritableResource(WritableResource resource, Origin origin) { + super(resource, origin); + } + + @Override + public WritableResource getResource() { + return (WritableResource) super.getResource(); + } + + @Override + public OutputStream getOutputStream() throws IOException { + return getResource().getOutputStream(); + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/origin/TextResourceOrigin.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/origin/TextResourceOrigin.java index c478d87d88..7477dcc8dc 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/origin/TextResourceOrigin.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/origin/TextResourceOrigin.java @@ -21,11 +21,14 @@ import org.springframework.util.ObjectUtils; /** * {@link Origin} for an item loaded from a text resource. Provides access to the original - * {@link Resource} that loaded the text and a {@link Location} within it. + * {@link Resource} that loaded the text and a {@link Location} within it. If the provided + * resource provides an {@link Origin} (e.g. it is an {@link OriginTrackedResource}), then + * it will be used as the {@link Origin#getParent() origin parent}. * * @author Madhura Bhave * @author Phillip Webb * @since 2.0.0 + * @see OriginTrackedResource */ public class TextResourceOrigin implements Origin { @@ -54,6 +57,11 @@ public class TextResourceOrigin implements Origin { return this.location; } + @Override + public Origin getParent() { + return Origin.from(this.resource); + } + @Override public boolean equals(Object obj) { if (this == obj) { diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/origin/MockOrigin.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/origin/MockOrigin.java index 18a85ad69c..a58fc57d59 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/origin/MockOrigin.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/origin/MockOrigin.java @@ -27,9 +27,17 @@ public final class MockOrigin implements Origin { private final String value; - private MockOrigin(String value) { + private final Origin parent; + + private MockOrigin(String value, Origin parent) { Assert.notNull(value, "Value must not be null"); this.value = value; + this.parent = parent; + } + + @Override + public Origin getParent() { + return this.parent; } @Override @@ -54,7 +62,11 @@ public final class MockOrigin implements Origin { } public static Origin of(String value) { - return (value != null) ? new MockOrigin(value) : null; + return of(value, null); + } + + public static Origin of(String value, Origin parent) { + return (value != null) ? new MockOrigin(value, parent) : null; } } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/origin/OriginTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/origin/OriginTests.java index 5489e6ef67..9d6d32f2b8 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/origin/OriginTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/origin/OriginTests.java @@ -16,6 +16,8 @@ package org.springframework.boot.origin; +import java.util.List; + import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @@ -29,6 +31,13 @@ import static org.mockito.Mockito.mock; */ class OriginTests { + @Test + void getParentWhenDefaultIsNull() { + Origin origin = new Origin() { + }; + assertThat(origin.getParent()).isNull(); + } + @Test void fromWhenSourceIsNullReturnsNull() { assertThat(Origin.from(null)).isNull(); @@ -68,6 +77,20 @@ class OriginTests { assertThat(Origin.from(exception)).isEqualTo(origin); } + @Test + void parentsFromWhenSourceIsNullReturnsEmptyList() { + assertThat(Origin.parentsFrom(null)).isEmpty(); + } + + @Test + void parentsFromReturnsParents() { + Origin o1 = MockOrigin.of("1"); + Origin o2 = MockOrigin.of("2", o1); + Origin o3 = MockOrigin.of("3", o2); + List parents = Origin.parentsFrom(o3); + assertThat(parents).containsExactly(o2, o1); + } + static class TestException extends RuntimeException implements OriginProvider { private final Origin origin; diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/origin/OriginTrackedResourceTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/origin/OriginTrackedResourceTests.java new file mode 100644 index 0000000000..7ebc7abd70 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/origin/OriginTrackedResourceTests.java @@ -0,0 +1,190 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.origin; + +import java.io.IOException; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.origin.OriginTrackedResource.OriginTrackedWritableResource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.WritableResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link OriginTrackedResource}. + * + * @author Phillip Webb + */ +class OriginTrackedResourceTests { + + private Origin origin; + + private WritableResource resource; + + private OriginTrackedWritableResource tracked; + + @BeforeEach + void setup() { + this.origin = MockOrigin.of("test"); + this.resource = mock(WritableResource.class); + this.tracked = OriginTrackedResource.of(this.resource, this.origin); + } + + @Test + void getInputStreamDelegatesToResource() throws IOException { + this.tracked.getInputStream(); + verify(this.resource).getInputStream(); + } + + @Test + void existsDelegatesToResource() { + this.tracked.exists(); + verify(this.resource).exists(); + } + + @Test + void isReadableDelegatesToResource() { + this.tracked.isReadable(); + verify(this.resource).isReadable(); + } + + @Test + void isOpenDelegatesToResource() { + this.tracked.isOpen(); + verify(this.resource).isOpen(); + } + + @Test + void isFileDelegatesToResource() { + this.tracked.isFile(); + verify(this.resource).isFile(); + } + + @Test + void getURLDelegatesToResource() throws IOException { + this.tracked.getURL(); + verify(this.resource).getURL(); + } + + @Test + void getURIDelegatesToResource() throws IOException { + this.tracked.getURI(); + verify(this.resource).getURI(); + } + + @Test + void getFileDelegatesToResource() throws IOException { + this.tracked.getFile(); + verify(this.resource).getFile(); + } + + @Test + void readableChannelDelegatesToResource() throws IOException { + this.tracked.readableChannel(); + verify(this.resource).readableChannel(); + } + + @Test + void contentLengthDelegatesToResource() throws IOException { + this.tracked.contentLength(); + verify(this.resource).contentLength(); + } + + @Test + void lastModifiedDelegatesToResource() throws IOException { + this.tracked.lastModified(); + verify(this.resource).lastModified(); + } + + @Test + void createRelativeDelegatesToResource() throws IOException { + this.tracked.createRelative("path"); + verify(this.resource).createRelative("path"); + } + + @Test + void getFilenameDelegatesToResource() { + this.tracked.getFilename(); + verify(this.resource).getFilename(); + } + + @Test + void getDescriptionDelegatesToResource() { + this.tracked.getDescription(); + verify(this.resource).getDescription(); + } + + @Test + void getOutputStreamDelegatesToResource() throws IOException { + this.tracked.getOutputStream(); + verify(this.resource).getOutputStream(); + } + + @Test + void toStringDelegatesToResource() { + Resource resource = new ClassPathResource("test"); + Resource tracked = OriginTrackedResource.of(resource, this.origin); + assertThat(tracked).hasToString(resource.toString()); + } + + @Test + void getOriginReturnsOrigin() { + assertThat(this.tracked.getOrigin()).isEqualTo(this.origin); + } + + @Test + void getResourceReturnsResource() { + assertThat(this.tracked.getResource()).isEqualTo(this.resource); + } + + @Test + void equalsAndHashCode() { + Origin o1 = MockOrigin.of("o1"); + Origin o2 = MockOrigin.of("o2"); + Resource r1 = mock(Resource.class); + Resource r2 = mock(Resource.class); + OriginTrackedResource r1o1a = OriginTrackedResource.of(r1, o1); + OriginTrackedResource r1o1b = OriginTrackedResource.of(r1, o1); + OriginTrackedResource r1o2 = OriginTrackedResource.of(r1, o2); + OriginTrackedResource r2o1 = OriginTrackedResource.of(r2, o1); + OriginTrackedResource r2o2 = OriginTrackedResource.of(r2, o2); + assertThat(r1o1a).isEqualTo(r1o1a).isEqualTo(r1o1a).isNotEqualTo(r1o2).isNotEqualTo(r2o1).isNotEqualTo(r2o2); + assertThat(r1o1a.hashCode()).isEqualTo(r1o1b.hashCode()); + } + + @Test + void ofReturnsOriginTrackedResource() { + Resource resource = mock(Resource.class); + Resource tracked = OriginTrackedResource.of(resource, this.origin); + assertThat(tracked).isExactlyInstanceOf(OriginTrackedResource.class); + } + + @Test + void ofWhenWritableReturnsOriginTrackedWrtiableResource() { + Resource resource = mock(WritableResource.class); + Resource tracked = OriginTrackedResource.of(resource, this.origin); + assertThat(tracked).isInstanceOf(WritableResource.class) + .isExactlyInstanceOf(OriginTrackedWritableResource.class); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/origin/TextResourceOriginTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/origin/TextResourceOriginTests.java index 550845ac26..30874d1dbd 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/origin/TextResourceOriginTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/origin/TextResourceOriginTests.java @@ -20,6 +20,7 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.origin.TextResourceOrigin.Location; import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; import static org.assertj.core.api.Assertions.assertThat; @@ -54,7 +55,21 @@ class TextResourceOriginTests { Location location = new Location(1, 2); TextResourceOrigin origin = new TextResourceOrigin(null, location); assertThat(origin.getLocation()).isEqualTo(location); + } + + @Test + void getParentWhenResourceIsNotOriginTrackedReturnsNull() { + ClassPathResource resource = new ClassPathResource("foo.txt"); + TextResourceOrigin origin = new TextResourceOrigin(resource, null); + assertThat(origin.getParent()).isNull(); + } + @Test + void getParentWhenResourceIsOriginTrackedReturnsResourceOrigin() { + Origin resourceOrigin = MockOrigin.of("test"); + Resource resource = OriginTrackedResource.of(new ClassPathResource("foo.txt"), resourceOrigin); + TextResourceOrigin origin = new TextResourceOrigin(resource, null); + assertThat(origin.getParent()).isSameAs(resourceOrigin); } @Test