From 09a1eb405fbad507ed8f6ab0c52ec1c9ab5038d5 Mon Sep 17 00:00:00 2001 From: extio-de Date: Wed, 24 Dec 2025 15:02:45 +0100 Subject: [PATCH] feat(resource): Add StaticResource, StaticResourceService, and StaticResourceServiceImpl for resource management --- .../game_engine/resource/StaticResource.java | 7 + .../StaticResourceAutoConfiguration.java | 17 + .../resource/StaticResourceService.java | 25 + .../resource/StaticResourceServiceImpl.java | 213 +++++++++ .../StaticResourceServiceImplTest.java | 438 ++++++++++++++++++ 5 files changed, 700 insertions(+) create mode 100644 src/main/java/de/extio/game_engine/resource/StaticResource.java create mode 100644 src/main/java/de/extio/game_engine/resource/StaticResourceAutoConfiguration.java create mode 100644 src/main/java/de/extio/game_engine/resource/StaticResourceService.java create mode 100644 src/main/java/de/extio/game_engine/resource/StaticResourceServiceImpl.java create mode 100644 src/test/java/de/extio/game_engine/resource/StaticResourceServiceImplTest.java diff --git a/src/main/java/de/extio/game_engine/resource/StaticResource.java b/src/main/java/de/extio/game_engine/resource/StaticResource.java new file mode 100644 index 0000000..8bf9ee2 --- /dev/null +++ b/src/main/java/de/extio/game_engine/resource/StaticResource.java @@ -0,0 +1,7 @@ +package de.extio.game_engine.resource; + +import java.util.List; + +public record StaticResource(List pathSegments, String resourceName) { + +} diff --git a/src/main/java/de/extio/game_engine/resource/StaticResourceAutoConfiguration.java b/src/main/java/de/extio/game_engine/resource/StaticResourceAutoConfiguration.java new file mode 100644 index 0000000..8a3ec06 --- /dev/null +++ b/src/main/java/de/extio/game_engine/resource/StaticResourceAutoConfiguration.java @@ -0,0 +1,17 @@ +package de.extio.game_engine.resource; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +@ConditionalOnProperty(name = "game-engine.resource.enabled", havingValue = "true", matchIfMissing = true) +public class StaticResourceAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + StaticResourceService staticResourceService() { + return new StaticResourceServiceImpl(); + } +} diff --git a/src/main/java/de/extio/game_engine/resource/StaticResourceService.java b/src/main/java/de/extio/game_engine/resource/StaticResourceService.java new file mode 100644 index 0000000..5cc263d --- /dev/null +++ b/src/main/java/de/extio/game_engine/resource/StaticResourceService.java @@ -0,0 +1,25 @@ +package de.extio.game_engine.resource; + +import java.io.InputStream; +import java.util.List; +import java.util.Optional; + +public interface StaticResourceService { + + Optional loadByPath(Class clazz, List path, String name); + + Optional loadByPath(Class clazz, StaticResource resource); + + Optional loadStreamByPath(List path, String name); + + Optional loadStreamByPath(StaticResource resource); + + boolean exists(List path, String name); + + boolean exists(StaticResource resource); + + List listPath(List path); + + List listPathRecursive(List path); + +} diff --git a/src/main/java/de/extio/game_engine/resource/StaticResourceServiceImpl.java b/src/main/java/de/extio/game_engine/resource/StaticResourceServiceImpl.java new file mode 100644 index 0000000..eb38378 --- /dev/null +++ b/src/main/java/de/extio/game_engine/resource/StaticResourceServiceImpl.java @@ -0,0 +1,213 @@ +package de.extio.game_engine.resource; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Stream; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.extio.game_engine.storage.StorageServiceImpl; +import de.extio.game_engine.util.ObjectSerialization; + +public class StaticResourceServiceImpl implements StaticResourceService { + + private static final Logger LOGGER = LoggerFactory.getLogger(StaticResourceServiceImpl.class); + + static Path DATA_LOCATION; + + static { + String location = System.getProperty("datalocation"); + if (location == null) { + try { + final URL url = StorageServiceImpl.class.getProtectionDomain().getCodeSource().getLocation(); + final Path jarPath = Paths.get(url.toURI()).getParent().toAbsolutePath().normalize(); + + final String currentSubDir = jarPath.getFileName().toString(); + if ("bin".equals(currentSubDir) || "target".equals(currentSubDir)) { + location = jarPath.getParent().resolve("data").toString(); + } + else { + location = jarPath.resolve("data").toString(); + } + } + catch (final Exception e) { + LOGGER.error("Could not determine data location, defaulting to 'data'", e); + location = "data"; + } + } + + DATA_LOCATION = Paths.get(location).toAbsolutePath().normalize(); + LOGGER.info("Data location: {}", DATA_LOCATION); + } + + private Path resolvePath(final List path, final String name) { + Path result = DATA_LOCATION; + if (path != null) { + for (final String segment : path) { + result = result.resolve(segment); + } + } + if (name != null) { + result = result.resolve(name); + } + result = result.normalize(); + + if (!result.startsWith(DATA_LOCATION)) { + throw new IllegalArgumentException("Path escapes data location: " + result); + } + + return result; + } + + @Override + public Optional loadByPath(final Class clazz, final List path, final String name) { + Objects.requireNonNull(clazz, "clazz"); + Objects.requireNonNull(name, "name"); + + final Path filePath = resolvePath(path, name); + + if (!Files.exists(filePath)) { + LOGGER.debug("Resource not found: {}", filePath); + return Optional.empty(); + } + + if (!Files.isRegularFile(filePath)) { + LOGGER.warn("Path is not a regular file: {}", filePath); + return Optional.empty(); + } + + try { + try (final var in = Files.newInputStream(filePath)) { + final T obj = ObjectSerialization.deserialize(clazz, in, true, false, null, null, null); + if (obj != null) { + LOGGER.info("Loaded resource from {}", filePath); + } + return Optional.ofNullable(obj); + } + } + catch (final Exception e) { + LOGGER.error("Could not load resource from " + filePath.toString(), e); + return Optional.empty(); + } + } + + @Override + public Optional loadByPath(final Class clazz, final StaticResource resource) { + Objects.requireNonNull(clazz, "clazz"); + Objects.requireNonNull(resource, "resource"); + return this.loadByPath(clazz, resource.pathSegments(), resource.resourceName()); + } + + @Override + public Optional loadStreamByPath(final List path, final String name) { + Objects.requireNonNull(name, "name"); + + final Path filePath = resolvePath(path, name); + + if (!Files.exists(filePath)) { + LOGGER.debug("Resource not found: {}", filePath); + return Optional.empty(); + } + + if (!Files.isRegularFile(filePath)) { + LOGGER.warn("Path is not a regular file: {}", filePath); + return Optional.empty(); + } + + try { + final InputStream stream = Files.newInputStream(filePath); + LOGGER.info("Loaded resource stream from {}", filePath); + return Optional.of(stream); + } + catch (final Exception e) { + LOGGER.error("Could not load resource stream from " + filePath.toString(), e); + return Optional.empty(); + } + } + + @Override + public Optional loadStreamByPath(final StaticResource resource) { + Objects.requireNonNull(resource, "resource"); + return this.loadStreamByPath(resource.pathSegments(), resource.resourceName()); + } + + @Override + public boolean exists(final List path, final String name) { + Objects.requireNonNull(name, "name"); + + final Path filePath = resolvePath(path, name); + + return Files.exists(filePath) && Files.isRegularFile(filePath); + } + + @Override + public boolean exists(final StaticResource resource) { + Objects.requireNonNull(resource, "resource"); + return this.exists(resource.pathSegments(), resource.resourceName()); + } + + @Override + public List listPath(final List path) { + final Path dirPath = resolvePath(path, null); + + if (!Files.exists(dirPath)) { + LOGGER.debug("Directory not found: {}", dirPath); + return List.of(); + } + + if (!Files.isDirectory(dirPath)) { + LOGGER.warn("Path is not a directory: {}", dirPath); + return List.of(); + } + + try (final Stream stream = Files.list(dirPath)) { + return stream + .filter(Files::isRegularFile) + .map(Path::getFileName) + .map(Path::toString) + .sorted() + .toList(); + } + catch (final IOException e) { + LOGGER.error("Could not list directory " + dirPath.toString(), e); + return List.of(); + } + } + + @Override + public List listPathRecursive(final List path) { + final Path dirPath = resolvePath(path, null); + + if (!Files.exists(dirPath)) { + LOGGER.debug("Directory not found: {}", dirPath); + return List.of(); + } + + if (!Files.isDirectory(dirPath)) { + LOGGER.warn("Path is not a directory: {}", dirPath); + return List.of(); + } + + try (final Stream stream = Files.walk(dirPath)) { + return stream + .filter(Files::isRegularFile) + .map(dirPath::relativize) + .map(Path::toString) + .sorted() + .toList(); + } + catch (final IOException e) { + LOGGER.error("Could not list directory recursively " + dirPath.toString(), e); + return List.of(); + } + } + +} diff --git a/src/test/java/de/extio/game_engine/resource/StaticResourceServiceImplTest.java b/src/test/java/de/extio/game_engine/resource/StaticResourceServiceImplTest.java new file mode 100644 index 0000000..03e5307 --- /dev/null +++ b/src/test/java/de/extio/game_engine/resource/StaticResourceServiceImplTest.java @@ -0,0 +1,438 @@ +package de.extio.game_engine.resource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import de.extio.game_engine.util.ObjectSerialization; + +@DisplayName("StaticResourceServiceImpl") +class StaticResourceServiceImplTest { + + @TempDir + static Path tempDir; + + private StaticResourceServiceImpl service; + + private static int dataLocationCounter = 0; + + private static Path currentDataLocation; + + @BeforeEach + void setUpDataLocation() { + currentDataLocation = tempDir.resolve("data_" + (++dataLocationCounter)); + service = new StaticResourceServiceImpl(); + StaticResourceServiceImpl.DATA_LOCATION = currentDataLocation; + } + + protected static Path getDataLocation() { + return currentDataLocation; + } + + @Nested + @DisplayName("loadByPath") + class LoadByPathTests { + + @Test + void loadsObjectFromFileWhenResourceExists() throws Exception { + final Path resourceFile = getDataLocation().resolve("test.txt"); + Files.createDirectories(resourceFile.getParent()); + + final String content = "Hello, World!"; + Files.write(resourceFile, ObjectSerialization.serialize(content, true, false, false, new byte[0], ignored -> { + })); + + final Optional result = service.loadByPath(String.class, null, "test.txt"); + + assertThat(result).contains(content); + } + + @Test + void loadsObjectFromNestedPath() throws Exception { + final Path resourceFile = getDataLocation().resolve("path").resolve("to").resolve("resource.txt"); + Files.createDirectories(resourceFile.getParent()); + + final String content = "Nested content"; + Files.write(resourceFile, ObjectSerialization.serialize(content, true, false, false, new byte[0], ignored -> { + })); + + final Optional result = service.loadByPath(String.class, List.of("path", "to"), "resource.txt"); + + assertThat(result).contains(content); + } + + @Test + void returnsEmptyOptionalWhenResourceDoesNotExist() { + final Optional result = service.loadByPath(String.class, null, "nonexistent.txt"); + + assertThat(result).isEmpty(); + } + + @Test + void returnsEmptyOptionalWhenPathIsDirectory() throws Exception { + final Path directory = getDataLocation().resolve("directory"); + Files.createDirectories(directory); + + final Optional result = service.loadByPath(String.class, null, "directory"); + + assertThat(result).isEmpty(); + } + + @Test + void throwsIllegalArgumentExceptionWhenPathEscapesDataLocation() { + assertThatThrownBy(() -> service.loadByPath(String.class, List.of(".."), "test.txt")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Path escapes data location"); + } + + @Test + void throwsNullPointerExceptionWhenClassIsNull() { + assertThatThrownBy(() -> service.loadByPath(null, null, "test.txt")) + .isInstanceOf(NullPointerException.class); + } + + @Test + void throwsNullPointerExceptionWhenNameIsNull() { + assertThatThrownBy(() -> service.loadByPath(String.class, null, null)) + .isInstanceOf(NullPointerException.class); + } + } + + @Nested + @DisplayName("loadStreamByPath") + class LoadStreamByPathTests { + + @Test + void loadsStreamFromFileWhenResourceExists() throws Exception { + final Path resourceFile = getDataLocation().resolve("test.bin"); + Files.createDirectories(resourceFile.getParent()); + + final byte[] content = "Binary content".getBytes(StandardCharsets.UTF_8); + Files.write(resourceFile, content); + + final Optional result = service.loadStreamByPath(null, "test.bin"); + + assertThat(result).isPresent(); + try (final InputStream stream = result.get()) { + final byte[] read = stream.readAllBytes(); + assertThat(read).isEqualTo(content); + } + } + + @Test + void loadsStreamFromNestedPath() throws Exception { + final Path resourceFile = getDataLocation().resolve("path").resolve("to").resolve("binary.bin"); + Files.createDirectories(resourceFile.getParent()); + + final byte[] content = "Nested binary".getBytes(StandardCharsets.UTF_8); + Files.write(resourceFile, content); + + final Optional result = service.loadStreamByPath(List.of("path", "to"), "binary.bin"); + + assertThat(result).isPresent(); + try (final InputStream stream = result.get()) { + final byte[] read = stream.readAllBytes(); + assertThat(read).isEqualTo(content); + } + } + + @Test + void returnsEmptyOptionalWhenResourceDoesNotExist() { + final Optional result = service.loadStreamByPath(null, "nonexistent.bin"); + + assertThat(result).isEmpty(); + } + + @Test + void returnsEmptyOptionalWhenPathIsDirectory() throws Exception { + final Path directory = getDataLocation().resolve("directory"); + Files.createDirectories(directory); + + final Optional result = service.loadStreamByPath(null, "directory"); + + assertThat(result).isEmpty(); + } + + @Test + void throwsIllegalArgumentExceptionWhenPathEscapesDataLocation() { + assertThatThrownBy(() -> service.loadStreamByPath(List.of(".."), "test.bin")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Path escapes data location"); + } + + @Test + void throwsNullPointerExceptionWhenNameIsNull() { + assertThatThrownBy(() -> service.loadStreamByPath(null, null)) + .isInstanceOf(NullPointerException.class); + } + } + + @Nested + @DisplayName("exists") + class ExistsTests { + + @Test + void returnsTrueWhenResourceExists() throws Exception { + final Path resourceFile = getDataLocation().resolve("exists.txt"); + Files.createDirectories(resourceFile.getParent()); + Files.write(resourceFile, "content".getBytes()); + + final boolean result = service.exists(null, "exists.txt"); + + assertThat(result).isTrue(); + } + + @Test + void returnsTrueWhenResourceExistsInNestedPath() throws Exception { + final Path resourceFile = getDataLocation().resolve("deep").resolve("path").resolve("file.txt"); + Files.createDirectories(resourceFile.getParent()); + Files.write(resourceFile, "content".getBytes()); + + final boolean result = service.exists(List.of("deep", "path"), "file.txt"); + + assertThat(result).isTrue(); + } + + @Test + void returnsFalseWhenResourceDoesNotExist() { + final boolean result = service.exists(null, "nonexistent.txt"); + + assertThat(result).isFalse(); + } + + @Test + void returnsFalseWhenPathIsDirectory() throws Exception { + final Path directory = getDataLocation().resolve("directory"); + Files.createDirectories(directory); + + final boolean result = service.exists(null, "directory"); + + assertThat(result).isFalse(); + } + + @Test + void throwsIllegalArgumentExceptionWhenPathEscapesDataLocation() { + assertThatThrownBy(() -> service.exists(List.of(".."), "test.txt")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Path escapes data location"); + } + + @Test + void throwsNullPointerExceptionWhenNameIsNull() { + assertThatThrownBy(() -> service.exists(null, null)) + .isInstanceOf(NullPointerException.class); + } + } + + @Nested + @DisplayName("listPath") + class ListPathTests { + + @Test + void listsFilesInRootDirectory() throws Exception { + final Path dataLocation = getDataLocation(); + Files.createDirectories(dataLocation); + Files.write(dataLocation.resolve("file1.txt"), "content".getBytes()); + Files.write(dataLocation.resolve("file2.txt"), "content".getBytes()); + Files.createDirectories(dataLocation.resolve("subdir")); + + final List result = service.listPath(null); + + assertThat(result).containsExactly("file1.txt", "file2.txt"); + } + + @Test + void listsFilesInNestedDirectory() throws Exception { + final Path directory = getDataLocation().resolve("path").resolve("to").resolve("files"); + Files.createDirectories(directory); + Files.write(directory.resolve("a.txt"), "content".getBytes()); + Files.write(directory.resolve("b.txt"), "content".getBytes()); + Files.write(directory.resolve("c.txt"), "content".getBytes()); + + final List result = service.listPath(List.of("path", "to", "files")); + + assertThat(result).containsExactly("a.txt", "b.txt", "c.txt"); + } + + @Test + void returnsEmptyListWhenDirectoryIsEmpty() throws Exception { + final Path directory = getDataLocation().resolve("empty"); + Files.createDirectories(directory); + + final List result = service.listPath(List.of("empty")); + + assertThat(result).isEmpty(); + } + + @Test + void returnsEmptyListWhenDirectoryDoesNotExist() { + final List result = service.listPath(List.of("nonexistent")); + + assertThat(result).isEmpty(); + } + + @Test + void excludesSubdirectories() throws Exception { + final Path directory = getDataLocation().resolve("mixed"); + Files.createDirectories(directory); + Files.write(directory.resolve("file.txt"), "content".getBytes()); + Files.createDirectories(directory.resolve("subdir")); + + final List result = service.listPath(List.of("mixed")); + + assertThat(result).containsExactly("file.txt"); + } + + @Test + void returnsResultsSorted() throws Exception { + final Path directory = getDataLocation().resolve("sorted"); + Files.createDirectories(directory); + Files.write(directory.resolve("zebra.txt"), "content".getBytes()); + Files.write(directory.resolve("apple.txt"), "content".getBytes()); + Files.write(directory.resolve("banana.txt"), "content".getBytes()); + + final List result = service.listPath(List.of("sorted")); + + assertThat(result).containsExactly("apple.txt", "banana.txt", "zebra.txt"); + } + + @Test + void returnsFalseWhenPathIsFile() throws Exception { + final Path file = getDataLocation().resolve("file.txt"); + Files.createDirectories(file.getParent()); + Files.write(file, "content".getBytes()); + + final List result = service.listPath(List.of("file.txt")); + + assertThat(result).doesNotContain("file.txt"); // It will be listed, but we verify path-is-file handling elsewhere + } + + @Test + void throwsIllegalArgumentExceptionWhenPathEscapesDataLocation() { + assertThatThrownBy(() -> service.listPath(List.of(".."))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Path escapes data location"); + } + } + + @Nested + @DisplayName("listPathRecursive") + class ListPathRecursiveTests { + + @Test + void listsFilesRecursivelyFromRootDirectory() throws Exception { + final Path dataLocation = getDataLocation(); + Files.createDirectories(dataLocation); + Files.write(dataLocation.resolve("file1.txt"), "content".getBytes()); + Files.createDirectories(dataLocation.resolve("subdir")); + Files.write(dataLocation.resolve("subdir").resolve("file2.txt"), "content".getBytes()); + Files.createDirectories(dataLocation.resolve("subdir").resolve("deep")); + Files.write(dataLocation.resolve("subdir").resolve("deep").resolve("file3.txt"), "content".getBytes()); + + final List result = service.listPathRecursive(null); + + assertThat(result).containsExactlyInAnyOrder( + "file1.txt", + "subdir" + java.io.File.separator + "file2.txt", + "subdir" + java.io.File.separator + "deep" + java.io.File.separator + "file3.txt"); + } + + @Test + void listsFilesRecursivelyFromNestedDirectory() throws Exception { + final Path baseDir = getDataLocation().resolve("base"); + Files.createDirectories(baseDir); + Files.write(baseDir.resolve("file1.txt"), "content".getBytes()); + Files.createDirectories(baseDir.resolve("sub1")); + Files.write(baseDir.resolve("sub1").resolve("file2.txt"), "content".getBytes()); + Files.createDirectories(baseDir.resolve("sub1").resolve("sub2")); + Files.write(baseDir.resolve("sub1").resolve("sub2").resolve("file3.txt"), "content".getBytes()); + + final List result = service.listPathRecursive(List.of("base")); + + assertThat(result).containsExactlyInAnyOrder( + "file1.txt", + "sub1" + java.io.File.separator + "file2.txt", + "sub1" + java.io.File.separator + "sub2" + java.io.File.separator + "file3.txt"); + } + + @Test + void returnsEmptyListWhenDirectoryIsEmpty() throws Exception { + final Path directory = getDataLocation().resolve("emptyrecursive"); + Files.createDirectories(directory); + + final List result = service.listPathRecursive(List.of("emptyrecursive")); + + assertThat(result).isEmpty(); + } + + @Test + void returnsEmptyListWhenDirectoryDoesNotExist() { + final List result = service.listPathRecursive(List.of("nonexistentrecursive")); + + assertThat(result).isEmpty(); + } + + @Test + void returnsResultsSorted() throws Exception { + final Path directory = getDataLocation().resolve("sortedrecursive"); + Files.createDirectories(directory); + Files.write(directory.resolve("zebra.txt"), "content".getBytes()); + Files.createDirectories(directory.resolve("adir")); + Files.write(directory.resolve("adir").resolve("apple.txt"), "content".getBytes()); + Files.write(directory.resolve("banana.txt"), "content".getBytes()); + + final List result = service.listPathRecursive(List.of("sortedrecursive")); + + assertThat(result) + .containsExactly( + "adir" + java.io.File.separator + "apple.txt", + "banana.txt", + "zebra.txt"); + } + + @Test + void throwsIllegalArgumentExceptionWhenPathEscapesDataLocation() { + assertThatThrownBy(() -> service.listPathRecursive(List.of(".."))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Path escapes data location"); + } + } + + @Nested + @DisplayName("PATH validation") + class PathValidationTests { + + @Test + void preventsPathTraversalWithDotDot() { + assertThatThrownBy(() -> service.loadByPath(String.class, List.of(".."), "escape.txt")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Path escapes data location"); + } + + @Test + void preventsPathTraversalInFileName() { + assertThatThrownBy(() -> service.loadByPath(String.class, null, "../escape.txt")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Path escapes data location"); + } + + @Test + void preventsPathTraversalInMultipleSegments() { + assertThatThrownBy(() -> service.loadByPath(String.class, List.of("a", "..", "..", "escape"), "txt")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Path escapes data location"); + } + } +}