implements PR-025 asset cache model and repository
This commit is contained in:
parent
6a9e09bd88
commit
2870a0677b
@ -0,0 +1,22 @@
|
|||||||
|
package p.packer.models;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
public record PackerAssetCacheEntry(
|
||||||
|
int assetId,
|
||||||
|
List<PackerFileCacheEntry> files) {
|
||||||
|
|
||||||
|
public PackerAssetCacheEntry {
|
||||||
|
if (assetId <= 0) {
|
||||||
|
throw new IllegalArgumentException("assetId must be positive");
|
||||||
|
}
|
||||||
|
files = List.copyOf(Objects.requireNonNull(files, "files"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<PackerFileCacheEntry> findFile(String relativePath) {
|
||||||
|
final String safeRelativePath = Objects.requireNonNull(relativePath, "relativePath").trim().replace('\\', '/');
|
||||||
|
return files.stream()
|
||||||
|
.filter(file -> file.relativePath().equals(safeRelativePath))
|
||||||
|
.findFirst();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
package p.packer.models;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public record PackerFileCacheEntry(
|
||||||
|
String relativePath,
|
||||||
|
String mimeType,
|
||||||
|
long size,
|
||||||
|
long lastModified,
|
||||||
|
String fingerprint,
|
||||||
|
Map<String, Object> metadata) {
|
||||||
|
|
||||||
|
public PackerFileCacheEntry {
|
||||||
|
relativePath = normalizeRelativePath(relativePath);
|
||||||
|
mimeType = normalizeOptional(mimeType);
|
||||||
|
if (size < 0L) {
|
||||||
|
throw new IllegalArgumentException("size must be non-negative");
|
||||||
|
}
|
||||||
|
if (lastModified < 0L) {
|
||||||
|
throw new IllegalArgumentException("lastModified must be non-negative");
|
||||||
|
}
|
||||||
|
fingerprint = normalizeOptional(fingerprint);
|
||||||
|
metadata = Map.copyOf(new LinkedHashMap<>(Objects.requireNonNull(metadata, "metadata")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeRelativePath(String relativePath) {
|
||||||
|
final String safeRelativePath = Objects.requireNonNull(relativePath, "relativePath")
|
||||||
|
.trim()
|
||||||
|
.replace('\\', '/');
|
||||||
|
if (safeRelativePath.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("relativePath must not be blank");
|
||||||
|
}
|
||||||
|
return safeRelativePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeOptional(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final String trimmed = value.trim();
|
||||||
|
return trimmed.isEmpty() ? null : trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
package p.packer.models;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public record PackerWorkspaceCacheState(
|
||||||
|
int schemaVersion,
|
||||||
|
List<PackerAssetCacheEntry> assets) {
|
||||||
|
public static final int CURRENT_SCHEMA_VERSION = 1;
|
||||||
|
public static final PackerWorkspaceCacheState EMPTY = new PackerWorkspaceCacheState(CURRENT_SCHEMA_VERSION, List.of());
|
||||||
|
|
||||||
|
public PackerWorkspaceCacheState {
|
||||||
|
if (schemaVersion <= 0) {
|
||||||
|
throw new IllegalArgumentException("schemaVersion must be positive");
|
||||||
|
}
|
||||||
|
assets = List.copyOf(Objects.requireNonNull(assets, "assets"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<PackerAssetCacheEntry> findAsset(int assetId) {
|
||||||
|
return assets.stream()
|
||||||
|
.filter(asset -> asset.assetId() == assetId)
|
||||||
|
.findFirst();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,165 @@
|
|||||||
|
package p.packer.repositories;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import p.packer.PackerWorkspacePaths;
|
||||||
|
import p.packer.exceptions.PackerRegistryException;
|
||||||
|
import p.packer.messages.PackerProjectContext;
|
||||||
|
import p.packer.models.PackerAssetCacheEntry;
|
||||||
|
import p.packer.models.PackerFileCacheEntry;
|
||||||
|
import p.packer.models.PackerWorkspaceCacheState;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
public final class FileSystemPackerCacheRepository {
|
||||||
|
private final ObjectMapper mapper;
|
||||||
|
|
||||||
|
public FileSystemPackerCacheRepository(ObjectMapper mapper) {
|
||||||
|
this.mapper = Objects.requireNonNull(mapper, "mapper");
|
||||||
|
}
|
||||||
|
|
||||||
|
public PackerWorkspaceCacheState load(PackerProjectContext project) {
|
||||||
|
final Path cachePath = PackerWorkspacePaths.cachePath(project);
|
||||||
|
if (!Files.isRegularFile(cachePath)) {
|
||||||
|
return PackerWorkspaceCacheState.EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final CacheDocument document = mapper.readValue(cachePath.toFile(), CacheDocument.class);
|
||||||
|
final int schemaVersion = document.schemaVersion <= 0
|
||||||
|
? PackerWorkspaceCacheState.CURRENT_SCHEMA_VERSION
|
||||||
|
: document.schemaVersion;
|
||||||
|
if (schemaVersion != PackerWorkspaceCacheState.CURRENT_SCHEMA_VERSION) {
|
||||||
|
throw new PackerRegistryException("Unsupported cache schema_version: " + schemaVersion);
|
||||||
|
}
|
||||||
|
final List<PackerAssetCacheEntry> assets = new ArrayList<>();
|
||||||
|
if (document.assets != null) {
|
||||||
|
for (CacheAssetDocument asset : document.assets) {
|
||||||
|
if (asset == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final List<PackerFileCacheEntry> files = new ArrayList<>();
|
||||||
|
if (asset.files != null) {
|
||||||
|
for (CacheFileDocument file : asset.files) {
|
||||||
|
if (file == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
files.add(new PackerFileCacheEntry(
|
||||||
|
normalizeRelativePath(file.relativePath),
|
||||||
|
file.mimeType,
|
||||||
|
file.size,
|
||||||
|
file.lastModified,
|
||||||
|
file.fingerprint,
|
||||||
|
file.metadata == null ? Map.of() : file.metadata));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validateAssetId(asset.assetId);
|
||||||
|
validateDuplicateRelativePaths(asset.assetId, files);
|
||||||
|
assets.add(new PackerAssetCacheEntry(asset.assetId, files));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validateDuplicateAssetIds(assets);
|
||||||
|
return new PackerWorkspaceCacheState(schemaVersion, assets.stream()
|
||||||
|
.sorted(Comparator.comparingInt(PackerAssetCacheEntry::assetId))
|
||||||
|
.toList());
|
||||||
|
} catch (IOException exception) {
|
||||||
|
throw new PackerRegistryException("Unable to load cache: " + cachePath, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void save(PackerProjectContext project, PackerWorkspaceCacheState state) {
|
||||||
|
final Path cachePath = PackerWorkspacePaths.cachePath(project);
|
||||||
|
final Path directory = PackerWorkspacePaths.registryDirectory(project);
|
||||||
|
final PackerWorkspaceCacheState safeState = Objects.requireNonNull(state, "state");
|
||||||
|
validateDuplicateAssetIds(safeState.assets());
|
||||||
|
safeState.assets().forEach(asset -> validateDuplicateRelativePaths(asset.assetId(), asset.files()));
|
||||||
|
try {
|
||||||
|
Files.createDirectories(directory);
|
||||||
|
final CacheDocument document = new CacheDocument();
|
||||||
|
document.schemaVersion = safeState.schemaVersion();
|
||||||
|
document.assets = safeState.assets().stream()
|
||||||
|
.map(asset -> new CacheAssetDocument(
|
||||||
|
asset.assetId(),
|
||||||
|
asset.files().stream()
|
||||||
|
.map(file -> new CacheFileDocument(
|
||||||
|
file.relativePath(),
|
||||||
|
file.mimeType(),
|
||||||
|
file.size(),
|
||||||
|
file.lastModified(),
|
||||||
|
file.fingerprint(),
|
||||||
|
file.metadata()))
|
||||||
|
.toList()))
|
||||||
|
.toList();
|
||||||
|
mapper.writerWithDefaultPrettyPrinter().writeValue(cachePath.toFile(), document);
|
||||||
|
} catch (IOException exception) {
|
||||||
|
throw new PackerRegistryException("Unable to save cache: " + cachePath, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateDuplicateAssetIds(List<PackerAssetCacheEntry> assets) {
|
||||||
|
final Set<Integer> assetIds = new HashSet<>();
|
||||||
|
for (PackerAssetCacheEntry asset : assets) {
|
||||||
|
validateAssetId(asset.assetId());
|
||||||
|
if (!assetIds.add(asset.assetId())) {
|
||||||
|
throw new PackerRegistryException("Duplicate asset_id in cache: " + asset.assetId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateDuplicateRelativePaths(int assetId, List<PackerFileCacheEntry> files) {
|
||||||
|
final Set<String> relativePaths = new HashSet<>();
|
||||||
|
for (PackerFileCacheEntry file : files) {
|
||||||
|
final String relativePath = normalizeRelativePath(file.relativePath());
|
||||||
|
if (!relativePaths.add(relativePath)) {
|
||||||
|
throw new PackerRegistryException("Duplicate cached file path for asset_id " + assetId + ": " + relativePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateAssetId(int assetId) {
|
||||||
|
if (assetId <= 0) {
|
||||||
|
throw new PackerRegistryException("Cache asset_id must be positive");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeRelativePath(String relativePath) {
|
||||||
|
final String normalized = Objects.requireNonNull(relativePath, "relativePath").trim().replace('\\', '/');
|
||||||
|
if (normalized.isBlank()) {
|
||||||
|
throw new PackerRegistryException("Cached relative path must not be blank");
|
||||||
|
}
|
||||||
|
final Path normalizedPath = Path.of(normalized).normalize();
|
||||||
|
if (normalizedPath.isAbsolute() || normalizedPath.startsWith("..")) {
|
||||||
|
throw new PackerRegistryException("Cached relative path is outside the trusted asset boundary: " + normalized);
|
||||||
|
}
|
||||||
|
return normalizedPath.toString().replace('\\', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
private static final class CacheDocument {
|
||||||
|
@JsonProperty("schema_version")
|
||||||
|
public int schemaVersion;
|
||||||
|
|
||||||
|
@JsonProperty("assets")
|
||||||
|
public List<CacheAssetDocument> assets = List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
private record CacheAssetDocument(
|
||||||
|
@JsonProperty("asset_id") int assetId,
|
||||||
|
@JsonProperty("files") List<CacheFileDocument> files) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
private record CacheFileDocument(
|
||||||
|
@JsonProperty("relative_path") String relativePath,
|
||||||
|
@JsonProperty("mime_type") String mimeType,
|
||||||
|
@JsonProperty("size") long size,
|
||||||
|
@JsonProperty("last_modified") long lastModified,
|
||||||
|
@JsonProperty("fingerprint") String fingerprint,
|
||||||
|
@JsonProperty("metadata") Map<String, Object> metadata) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,142 @@
|
|||||||
|
package p.packer.repositories;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import p.packer.PackerWorkspacePaths;
|
||||||
|
import p.packer.exceptions.PackerRegistryException;
|
||||||
|
import p.packer.messages.PackerProjectContext;
|
||||||
|
import p.packer.models.PackerAssetCacheEntry;
|
||||||
|
import p.packer.models.PackerFileCacheEntry;
|
||||||
|
import p.packer.models.PackerWorkspaceCacheState;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
final class FileSystemPackerCacheRepositoryTest {
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsEmptyStateWhenCacheIsAbsent() {
|
||||||
|
final var repository = new FileSystemPackerCacheRepository(MAPPER);
|
||||||
|
|
||||||
|
final var state = repository.load(project(tempDir.resolve("project")));
|
||||||
|
|
||||||
|
assertEquals(PackerWorkspaceCacheState.EMPTY, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void savesAndLoadsCacheRoundTrip() throws Exception {
|
||||||
|
final var repository = new FileSystemPackerCacheRepository(MAPPER);
|
||||||
|
final var project = project(tempDir.resolve("project"));
|
||||||
|
final var state = new PackerWorkspaceCacheState(
|
||||||
|
PackerWorkspaceCacheState.CURRENT_SCHEMA_VERSION,
|
||||||
|
List.of(
|
||||||
|
new PackerAssetCacheEntry(3, List.of(
|
||||||
|
new PackerFileCacheEntry(
|
||||||
|
"tiles/ui.png",
|
||||||
|
"image/png",
|
||||||
|
128L,
|
||||||
|
42L,
|
||||||
|
"abc123",
|
||||||
|
Map.of("tile_count", 4)),
|
||||||
|
new PackerFileCacheEntry(
|
||||||
|
"tiles/ui-2.png",
|
||||||
|
"image/png",
|
||||||
|
256L,
|
||||||
|
84L,
|
||||||
|
"def456",
|
||||||
|
Map.of()))),
|
||||||
|
new PackerAssetCacheEntry(1, List.of(
|
||||||
|
new PackerFileCacheEntry(
|
||||||
|
"audio/click.wav",
|
||||||
|
"audio/wav",
|
||||||
|
512L,
|
||||||
|
21L,
|
||||||
|
null,
|
||||||
|
Map.of("sample_count", 8))))));
|
||||||
|
|
||||||
|
repository.save(project, state);
|
||||||
|
final var loaded = repository.load(project);
|
||||||
|
|
||||||
|
assertEquals(List.of(1, 3), loaded.assets().stream().map(PackerAssetCacheEntry::assetId).toList());
|
||||||
|
assertTrue(loaded.findAsset(3).flatMap(asset -> asset.findFile("tiles/ui.png")).isPresent());
|
||||||
|
assertEquals(128L, loaded.findAsset(3).orElseThrow().findFile("tiles/ui.png").orElseThrow().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMalformedCacheFile() throws Exception {
|
||||||
|
final var repository = new FileSystemPackerCacheRepository(MAPPER);
|
||||||
|
final var project = project(tempDir.resolve("project"));
|
||||||
|
Files.createDirectories(PackerWorkspacePaths.registryDirectory(project));
|
||||||
|
Files.writeString(PackerWorkspacePaths.cachePath(project), "{ nope ");
|
||||||
|
|
||||||
|
final var exception = assertThrows(PackerRegistryException.class, () -> repository.load(project));
|
||||||
|
|
||||||
|
assertTrue(exception.getMessage().contains("Unable to load cache"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUnsupportedSchemaVersion() throws Exception {
|
||||||
|
final var repository = new FileSystemPackerCacheRepository(MAPPER);
|
||||||
|
final var project = project(tempDir.resolve("project"));
|
||||||
|
Files.createDirectories(PackerWorkspacePaths.registryDirectory(project));
|
||||||
|
Files.writeString(PackerWorkspacePaths.cachePath(project), """
|
||||||
|
{
|
||||||
|
"schema_version": 999,
|
||||||
|
"assets": []
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
final var exception = assertThrows(PackerRegistryException.class, () -> repository.load(project));
|
||||||
|
|
||||||
|
assertTrue(exception.getMessage().contains("Unsupported cache schema_version"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void alignsLookupByAssetId() {
|
||||||
|
final var state = new PackerWorkspaceCacheState(
|
||||||
|
PackerWorkspaceCacheState.CURRENT_SCHEMA_VERSION,
|
||||||
|
List.of(
|
||||||
|
new PackerAssetCacheEntry(7, List.of()),
|
||||||
|
new PackerAssetCacheEntry(9, List.of())));
|
||||||
|
|
||||||
|
assertTrue(state.findAsset(7).isPresent());
|
||||||
|
assertTrue(state.findAsset(8).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotSerializeDiagnostics() throws Exception {
|
||||||
|
final var repository = new FileSystemPackerCacheRepository(MAPPER);
|
||||||
|
final var project = project(tempDir.resolve("project"));
|
||||||
|
final var state = new PackerWorkspaceCacheState(
|
||||||
|
PackerWorkspaceCacheState.CURRENT_SCHEMA_VERSION,
|
||||||
|
List.of(new PackerAssetCacheEntry(1, List.of(
|
||||||
|
new PackerFileCacheEntry(
|
||||||
|
"tiles/ui.png",
|
||||||
|
"image/png",
|
||||||
|
128L,
|
||||||
|
42L,
|
||||||
|
"abc123",
|
||||||
|
Map.of("warning_count", 2))))));
|
||||||
|
|
||||||
|
repository.save(project, state);
|
||||||
|
final String json = Files.readString(PackerWorkspacePaths.cachePath(project));
|
||||||
|
|
||||||
|
assertFalse(json.contains("diagnostic"));
|
||||||
|
assertFalse(json.contains("diagnostics"));
|
||||||
|
assertTrue(json.contains("\"asset_id\""));
|
||||||
|
assertTrue(json.contains("\"metadata\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
private PackerProjectContext project(Path rootPath) {
|
||||||
|
return new PackerProjectContext("main", rootPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user