318 lines
13 KiB
Java
318 lines
13 KiB
Java
package p.packer.services;
|
|
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import p.packer.messages.assets.AssetFamilyCatalog;
|
|
import p.packer.messages.assets.OutputCodecCatalog;
|
|
import p.packer.messages.assets.OutputFormatCatalog;
|
|
import p.packer.messages.diagnostics.PackerDiagnosticCategory;
|
|
import p.packer.messages.diagnostics.PackerDiagnosticSeverity;
|
|
import p.packer.models.PackerAssetArtifactSelection;
|
|
import p.packer.models.PackerAssetDeclaration;
|
|
import p.packer.models.PackerAssetDeclarationParseResult;
|
|
import p.packer.models.PackerDiagnostic;
|
|
|
|
import java.io.IOException;
|
|
import java.nio.file.Path;
|
|
import java.util.*;
|
|
|
|
public final class PackerAssetDeclarationParser {
|
|
private static final Set<Integer> SUPPORTED_SCHEMA_VERSIONS = Set.of(1);
|
|
|
|
private final ObjectMapper mapper;
|
|
|
|
public PackerAssetDeclarationParser(ObjectMapper mapper) {
|
|
this.mapper = Objects.requireNonNull(mapper, "mapper");
|
|
}
|
|
|
|
public PackerAssetDeclarationParseResult parse(final Path assetManifestPath) {
|
|
final var manifestPath = Objects.requireNonNull(assetManifestPath, "assetManifestPath").toAbsolutePath().normalize();
|
|
final List<PackerDiagnostic> diagnostics = new ArrayList<>();
|
|
final JsonNode root;
|
|
try {
|
|
root = mapper.readTree(manifestPath.toFile());
|
|
} catch (IOException exception) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Unable to parse asset.json: " + exception.getMessage(),
|
|
manifestPath,
|
|
true));
|
|
return new PackerAssetDeclarationParseResult(null, diagnostics);
|
|
}
|
|
|
|
final var schemaVersion = requiredInt(root, "schema_version", diagnostics, manifestPath);
|
|
final var assetUuid = requiredText(root, "asset_uuid", diagnostics, manifestPath);
|
|
final var name = requiredText(root, "name", diagnostics, manifestPath);
|
|
final var assetFamily = requiredAssetFamily(root, diagnostics, manifestPath);
|
|
rejectLegacyInputs(root.path("inputs"), diagnostics, manifestPath);
|
|
final var artifacts = optionalArtifacts(root.path("artifacts"), diagnostics, manifestPath);
|
|
final var outputFormat = requiredOutputFormat(root.path("output"), diagnostics, manifestPath);
|
|
final var outputCodec = requiredOutputCodec(root.path("output"), diagnostics, manifestPath);
|
|
final var outputMetadata = optionalOutputMetadata(root.path("output"), diagnostics, manifestPath);
|
|
final var preloadEnabled = requiredBoolean(root.path("preload"), "enabled", diagnostics, manifestPath);
|
|
|
|
if (schemaVersion != null && !SUPPORTED_SCHEMA_VERSIONS.contains(schemaVersion)) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.VERSIONING,
|
|
"Unsupported asset.json schema_version: " + schemaVersion,
|
|
manifestPath,
|
|
true));
|
|
}
|
|
|
|
if (diagnostics.stream().anyMatch(PackerDiagnostic::blocking)) {
|
|
return new PackerAssetDeclarationParseResult(null, diagnostics);
|
|
}
|
|
|
|
return new PackerAssetDeclarationParseResult(
|
|
new PackerAssetDeclaration(
|
|
schemaVersion,
|
|
assetUuid,
|
|
name,
|
|
assetFamily,
|
|
artifacts,
|
|
outputFormat,
|
|
outputCodec,
|
|
outputMetadata,
|
|
preloadEnabled),
|
|
diagnostics);
|
|
}
|
|
|
|
private Integer requiredInt(
|
|
final JsonNode node,
|
|
final String fieldName,
|
|
final List<PackerDiagnostic> diagnostics,
|
|
final Path manifestPath) {
|
|
final JsonNode field = node.path(fieldName);
|
|
if (!field.isInt()) {
|
|
diagnostics.add(missingOrInvalid(fieldName, "integer", manifestPath));
|
|
return null;
|
|
}
|
|
return field.intValue();
|
|
}
|
|
|
|
private String requiredText(
|
|
final JsonNode node,
|
|
final String fieldName,
|
|
final List<PackerDiagnostic> diagnostics,
|
|
final Path manifestPath) {
|
|
final JsonNode field = node.path(fieldName);
|
|
if (!field.isTextual() || field.asText().isBlank()) {
|
|
diagnostics.add(missingOrInvalid(fieldName, "non-blank string", manifestPath));
|
|
return null;
|
|
}
|
|
return field.asText().trim();
|
|
}
|
|
|
|
private AssetFamilyCatalog requiredAssetFamily(
|
|
final JsonNode node,
|
|
final List<PackerDiagnostic> diagnostics,
|
|
final Path manifestPath) {
|
|
final String manifestType = requiredText(node, "type", diagnostics, manifestPath);
|
|
if (manifestType == null) {
|
|
return null;
|
|
}
|
|
final AssetFamilyCatalog assetFamily = AssetFamilyCatalog.fromManifestType(manifestType);
|
|
if (assetFamily == AssetFamilyCatalog.UNKNOWN) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Field 'type' must be one of: tile_bank, palette_bank, sound_bank.",
|
|
manifestPath,
|
|
true));
|
|
return null;
|
|
}
|
|
return assetFamily;
|
|
}
|
|
|
|
private Boolean requiredBoolean(
|
|
final JsonNode node,
|
|
final String fieldName,
|
|
final List<PackerDiagnostic> diagnostics,
|
|
final Path manifestPath) {
|
|
final JsonNode field = node.path(fieldName);
|
|
if (!field.isBoolean()) {
|
|
diagnostics.add(missingOrInvalid(fieldName, "boolean", manifestPath));
|
|
return null;
|
|
}
|
|
return field.booleanValue();
|
|
}
|
|
|
|
private OutputFormatCatalog requiredOutputFormat(
|
|
final JsonNode node,
|
|
final List<PackerDiagnostic> diagnostics,
|
|
final Path manifestPath) {
|
|
final String fmtValue = requiredText(node, "format", diagnostics, manifestPath);
|
|
if (fmtValue == null) {
|
|
return null;
|
|
}
|
|
final OutputFormatCatalog outputFormat = OutputFormatCatalog.fromManifestValue(fmtValue);
|
|
if (outputFormat == OutputFormatCatalog.UNKNOWN) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Field 'output' must have a valid value.",
|
|
manifestPath,
|
|
true));
|
|
return null;
|
|
}
|
|
return outputFormat;
|
|
}
|
|
|
|
private OutputCodecCatalog requiredOutputCodec(
|
|
final JsonNode node,
|
|
final List<PackerDiagnostic> diagnostics,
|
|
final Path manifestPath) {
|
|
final String codecValue = requiredText(node, "codec", diagnostics, manifestPath);
|
|
if (codecValue == null) {
|
|
return null;
|
|
}
|
|
final OutputCodecCatalog outputCodec = OutputCodecCatalog.fromManifestValue(codecValue);
|
|
if (outputCodec == OutputCodecCatalog.UNKNOWN) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Field 'codec' must have a valid value.",
|
|
manifestPath,
|
|
true));
|
|
return null;
|
|
}
|
|
return outputCodec;
|
|
}
|
|
|
|
private Map<String, String> optionalOutputMetadata(
|
|
final JsonNode node,
|
|
final List<PackerDiagnostic> diagnostics,
|
|
final Path manifestPath) {
|
|
final JsonNode metadataNode = node.path("metadata");
|
|
if (metadataNode.isMissingNode() || metadataNode.isNull()) {
|
|
return Map.of();
|
|
}
|
|
if (!metadataNode.isObject()) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Field 'output.metadata' must be a JSON object.",
|
|
manifestPath,
|
|
true));
|
|
return Map.of();
|
|
}
|
|
|
|
final Map<String, String> metadata = new LinkedHashMap<>();
|
|
metadataNode.fields().forEachRemaining(entry -> {
|
|
final String key = Objects.requireNonNullElse(entry.getKey(), "").trim();
|
|
if (key.isBlank()) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Field 'output.metadata' has an invalid empty key.",
|
|
manifestPath,
|
|
true));
|
|
return;
|
|
}
|
|
|
|
final JsonNode valueNode = entry.getValue();
|
|
if (valueNode == null || valueNode.isContainerNode()) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Field 'output.metadata' values must be scalar (text/number/boolean).",
|
|
manifestPath,
|
|
true));
|
|
return;
|
|
}
|
|
metadata.put(key, valueNode.asText());
|
|
});
|
|
|
|
return Map.copyOf(metadata);
|
|
}
|
|
|
|
private void rejectLegacyInputs(
|
|
final JsonNode node,
|
|
final List<PackerDiagnostic> diagnostics,
|
|
final Path manifestPath) {
|
|
if (node.isMissingNode() || node.isNull()) {
|
|
return;
|
|
}
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Field 'inputs' is no longer supported; use 'artifacts' instead.",
|
|
manifestPath,
|
|
true));
|
|
}
|
|
|
|
private List<PackerAssetArtifactSelection> optionalArtifacts(
|
|
final JsonNode node,
|
|
final List<PackerDiagnostic> diagnostics,
|
|
final Path manifestPath) {
|
|
if (node.isMissingNode() || node.isNull()) {
|
|
return List.of();
|
|
}
|
|
if (!node.isArray()) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Field 'artifacts' must be an array.",
|
|
manifestPath,
|
|
true));
|
|
return List.of();
|
|
}
|
|
|
|
final List<PackerAssetArtifactSelection> result = new ArrayList<>();
|
|
for (JsonNode entry : node) {
|
|
if (!entry.isObject()) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Each artifact entry must be an object with 'file' and 'index'.",
|
|
manifestPath,
|
|
true));
|
|
continue;
|
|
}
|
|
final JsonNode fileNode = entry.path("file");
|
|
final JsonNode indexNode = entry.path("index");
|
|
if (!fileNode.isTextual() || fileNode.asText().isBlank() || !indexNode.isInt() || indexNode.asInt() < 0) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Each artifact entry must define a non-blank 'file' and a non-negative integer 'index'.",
|
|
manifestPath,
|
|
true));
|
|
continue;
|
|
}
|
|
final String relativePath = fileNode.asText().trim();
|
|
if (!isTrustedRelativePath(relativePath)) {
|
|
diagnostics.add(new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Artifact file path must stay inside the asset root.",
|
|
manifestPath,
|
|
true));
|
|
continue;
|
|
}
|
|
result.add(new PackerAssetArtifactSelection(relativePath, indexNode.asInt()));
|
|
}
|
|
result.sort(Comparator.comparingInt(PackerAssetArtifactSelection::index));
|
|
return List.copyOf(result);
|
|
}
|
|
|
|
private boolean isTrustedRelativePath(final String value) {
|
|
final Path path = Path.of(value).normalize();
|
|
return !path.isAbsolute() && !path.startsWith("..");
|
|
}
|
|
|
|
private PackerDiagnostic missingOrInvalid(
|
|
final String fieldName,
|
|
final String expected,
|
|
final Path manifestPath) {
|
|
return new PackerDiagnostic(
|
|
PackerDiagnosticSeverity.ERROR,
|
|
PackerDiagnosticCategory.STRUCTURAL,
|
|
"Field '" + fieldName + "' must be a " + expected + ".",
|
|
manifestPath,
|
|
true);
|
|
}
|
|
}
|