feat(resource): Add StaticResource, StaticResourceService, and StaticResourceServiceImpl for resource management

This commit is contained in:
extio-de
2025-12-24 15:02:45 +01:00
parent 9c375ceb0d
commit 09a1eb405f
5 changed files with 700 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
package de.extio.game_engine.resource;
import java.util.List;
public record StaticResource(List<String> pathSegments, String resourceName) {
}

View File

@@ -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();
}
}

View File

@@ -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 {
<T> Optional<T> loadByPath(Class<T> clazz, List<String> path, String name);
<T> Optional<T> loadByPath(Class<T> clazz, StaticResource resource);
Optional<InputStream> loadStreamByPath(List<String> path, String name);
Optional<InputStream> loadStreamByPath(StaticResource resource);
boolean exists(List<String> path, String name);
boolean exists(StaticResource resource);
List<String> listPath(List<String> path);
List<String> listPathRecursive(List<String> path);
}

View File

@@ -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<String> 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 <T> Optional<T> loadByPath(final Class<T> clazz, final List<String> 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 <T> Optional<T> loadByPath(final Class<T> clazz, final StaticResource resource) {
Objects.requireNonNull(clazz, "clazz");
Objects.requireNonNull(resource, "resource");
return this.loadByPath(clazz, resource.pathSegments(), resource.resourceName());
}
@Override
public Optional<InputStream> loadStreamByPath(final List<String> 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<InputStream> loadStreamByPath(final StaticResource resource) {
Objects.requireNonNull(resource, "resource");
return this.loadStreamByPath(resource.pathSegments(), resource.resourceName());
}
@Override
public boolean exists(final List<String> 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<String> listPath(final List<String> 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<Path> 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<String> listPathRecursive(final List<String> 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<Path> 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();
}
}
}

View File

@@ -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<String> 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<String> result = service.loadByPath(String.class, List.of("path", "to"), "resource.txt");
assertThat(result).contains(content);
}
@Test
void returnsEmptyOptionalWhenResourceDoesNotExist() {
final Optional<String> 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<String> 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<InputStream> 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<InputStream> 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<InputStream> 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<InputStream> 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<String> 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<String> 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<String> result = service.listPath(List.of("empty"));
assertThat(result).isEmpty();
}
@Test
void returnsEmptyListWhenDirectoryDoesNotExist() {
final List<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> result = service.listPathRecursive(List.of("emptyrecursive"));
assertThat(result).isEmpty();
}
@Test
void returnsEmptyListWhenDirectoryDoesNotExist() {
final List<String> 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<String> 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");
}
}
}