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