ExperimentDocumentCodec.java
package org.hammer.audio.experiment.document;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.StreamReadFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import org.hammer.audio.plugin.document.DocumentValue;
import org.hammer.audio.workflow.Workflow;
import org.hammer.audio.workflow.dsl.WorkflowDslParser;
import org.hammer.audio.workflow.dsl.WorkflowDslSerializer;
/** Strict parser, validator, normalizer and canonical serializer for {@code .audioexp} files. */
@SuppressWarnings({"PMD.CouplingBetweenObjects", "PMD.AvoidDuplicateLiterals", "PMD.LooseCoupling"})
public final class ExperimentDocumentCodec {
private static final Set<String> ROOT_FIELDS =
Set.of(
"$schema",
"format",
"formatVersion",
"experiment",
"workflow",
"profiles",
"requiredPlugins",
"pluginData",
"assets",
"outputs",
"provenance");
private static final Set<String> EXPERIMENT_FIELDS =
Set.of(
"id",
"name",
"description",
"tags",
"intent",
"sourceMode",
"intendedDuration",
"applicationVersion");
private static final Set<String> WORKFLOW_FIELDS =
Set.of("format", "formatVersion", "content", "sha256");
private static final Set<String> REQUIREMENT_FIELDS = Set.of("id", "versionRange", "sections");
private static final Set<String> PLUGIN_SECTION_FIELDS =
Set.of("schemaVersion", "algorithmVersion", "data");
private static final Set<String> ASSET_FIELDS =
Set.of("id", "relativePath", "mediaType", "sizeBytes", "sha256");
private static final Set<String> OUTPUT_FIELDS = Set.of("id", "mediaType", "baseName");
private static final Set<String> PROVENANCE_FIELDS =
Set.of(
"creatorDisplayName",
"verifiedAccount",
"createdAt",
"modifiedAt",
"softwareVersion",
"canonicalSha256",
"migrationNotes");
private final ObjectMapper mapper;
private final WorkflowDslParser workflowParser = new WorkflowDslParser();
private final WorkflowDslSerializer workflowSerializer = new WorkflowDslSerializer();
/** Create a codec with duplicate-key detection and no polymorphic deserialization. */
public ExperimentDocumentCodec() {
JsonFactory factory =
JsonFactory.builder().enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION).build();
mapper = new ObjectMapper(factory);
}
/** Parse, validate and normalize bounded UTF-8 JSON bytes. */
public ExperimentDocument decode(byte[] bytes) throws ExperimentDocumentException {
if (bytes == null || bytes.length == 0) {
throw failure("/", "empty-document", "Experiment document is empty");
}
if (bytes.length > ExperimentDocumentFormat.MAX_DOCUMENT_BYTES) {
throw failure("/", "max-bytes", "Experiment document exceeds the byte limit");
}
JsonNode parsed;
try {
parsed = mapper.readTree(bytes);
} catch (IOException exception) {
throw failure("/", "invalid-json", "Experiment document is not strict JSON", exception);
}
ObjectNode root = requireObject(parsed, "/");
rejectUnknown(root, ROOT_FIELDS, "/");
ExperimentDocument document = parseDocument(root);
ExperimentDocument normalized = normalizeWorkflow(document, true);
String expectedHash = canonicalHash(normalized);
if (!expectedHash.equals(normalized.provenance().canonicalSha256())) {
throw failure(
"/provenance/canonicalSha256",
"document-hash-mismatch",
"Canonical document SHA-256 does not match its contents");
}
return normalized;
}
/** Normalize and serialize a document to byte-stable canonical UTF-8 JSON. */
public byte[] encode(ExperimentDocument document) throws ExperimentDocumentException {
ExperimentDocument normalized = normalizeWorkflow(document, false);
String hash = canonicalHash(normalized);
ExperimentDocument withHash = withCanonicalHash(normalized, hash);
return writeTree(toTree(withHash, true));
}
/** Return the canonical hash without modifying the supplied document. */
public String canonicalHash(ExperimentDocument document) throws ExperimentDocumentException {
ExperimentDocument withoutHash = withCanonicalHash(document, "");
return DocumentHashes.sha256(writeTree(toTree(withoutHash, false)));
}
/** Atomically normalize and save to the requested path through a sibling temporary file. */
public void save(java.nio.file.Path target, ExperimentDocument document) throws IOException {
byte[] bytes = encode(document);
java.nio.file.Path normalized = target.toAbsolutePath().normalize();
java.nio.file.Path parent = normalized.getParent();
java.nio.file.Path fileName = normalized.getFileName();
if (parent == null || fileName == null) {
throw new IOException("Experiment document target has no parent or filename: " + normalized);
}
java.nio.file.Files.createDirectories(parent);
java.nio.file.Path temporary = normalized.resolveSibling(fileName.toString() + ".partial");
java.nio.file.Files.write(
temporary,
bytes,
java.nio.file.StandardOpenOption.CREATE,
java.nio.file.StandardOpenOption.TRUNCATE_EXISTING,
java.nio.file.StandardOpenOption.WRITE);
try {
java.nio.file.Files.move(
temporary,
normalized,
java.nio.file.StandardCopyOption.ATOMIC_MOVE,
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
} catch (java.nio.file.AtomicMoveNotSupportedException exception) {
java.nio.file.Files.move(
temporary, normalized, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
}
/** Read and decode one bounded document from disk. */
public ExperimentDocument load(java.nio.file.Path source) throws IOException {
long size = java.nio.file.Files.size(source);
if (size > ExperimentDocumentFormat.MAX_DOCUMENT_BYTES) {
throw failure("/", "max-bytes", "Experiment document exceeds the byte limit");
}
return decode(java.nio.file.Files.readAllBytes(source));
}
private ExperimentDocument parseDocument(ObjectNode root) throws ExperimentDocumentException {
String schema = requiredText(root, "$schema", "/$schema");
String format = requiredText(root, "format", "/format");
int version = requiredPositiveInt(root, "formatVersion", "/formatVersion");
if (!ExperimentDocumentFormat.FORMAT_ID.equals(format)) {
throw failure("/format", "unsupported-format", "Unsupported experiment document format");
}
if (version != ExperimentDocumentFormat.VERSION) {
throw failure(
"/formatVersion",
"unsupported-version",
"Unsupported experiment document version: " + version);
}
if (!ExperimentDocumentFormat.SCHEMA_RESOURCE.equals(schema)) {
throw failure("/$schema", "unsupported-schema", "Unsupported experiment document schema");
}
return new ExperimentDocument(
schema,
format,
version,
parseExperiment(requiredObject(root, "experiment", "/experiment")),
parseWorkflow(requiredObject(root, "workflow", "/workflow")),
parseProfiles(requiredObject(root, "profiles", "/profiles")),
parseRequirements(requiredArray(root, "requiredPlugins", "/requiredPlugins")),
parsePluginData(requiredObject(root, "pluginData", "/pluginData")),
parseAssets(requiredArray(root, "assets", "/assets")),
parseOutputs(requiredArray(root, "outputs", "/outputs")),
parseProvenance(requiredObject(root, "provenance", "/provenance")));
}
private ExperimentDocument.ExperimentInfo parseExperiment(ObjectNode node)
throws ExperimentDocumentException {
rejectUnknown(node, EXPERIMENT_FIELDS, "/experiment");
String duration = optionalText(node, "intendedDuration", "/experiment/intendedDuration");
return new ExperimentDocument.ExperimentInfo(
requiredText(node, "id", "/experiment/id"),
requiredText(node, "name", "/experiment/name"),
optionalText(node, "description", "/experiment/description"),
textArray(requiredArray(node, "tags", "/experiment/tags"), "/experiment/tags"),
requiredText(node, "intent", "/experiment/intent"),
requiredText(node, "sourceMode", "/experiment/sourceMode"),
duration.isBlank() ? null : parseDuration(duration, "/experiment/intendedDuration"),
requiredText(node, "applicationVersion", "/experiment/applicationVersion"));
}
private ExperimentDocument.WorkflowPayload parseWorkflow(ObjectNode node)
throws ExperimentDocumentException {
rejectUnknown(node, WORKFLOW_FIELDS, "/workflow");
return new ExperimentDocument.WorkflowPayload(
requiredText(node, "format", "/workflow/format"),
requiredPositiveInt(node, "formatVersion", "/workflow/formatVersion"),
requiredText(node, "content", "/workflow/content"),
requiredText(node, "sha256", "/workflow/sha256"));
}
private DocumentValue.ObjectValue parseProfiles(ObjectNode node)
throws ExperimentDocumentException {
DocumentValue value = DocumentValueJson.fromJson(node);
if (value instanceof DocumentValue.ObjectValue objectValue) {
return objectValue;
}
throw failure("/profiles", "expected-object", "profiles must be an object");
}
private List<ExperimentDocument.PluginRequirement> parseRequirements(ArrayNode array)
throws ExperimentDocumentException {
requireCollectionLimit(array.size(), "/requiredPlugins");
ArrayList<ExperimentDocument.PluginRequirement> result = new ArrayList<>();
for (int index = 0; index < array.size(); index++) {
String pointer = "/requiredPlugins/" + index;
ObjectNode item = requireObject(array.get(index), pointer);
rejectUnknown(item, REQUIREMENT_FIELDS, pointer);
result.add(
new ExperimentDocument.PluginRequirement(
requiredText(item, "id", pointer + "/id"),
requiredText(item, "versionRange", pointer + "/versionRange"),
textArray(
requiredArray(item, "sections", pointer + "/sections"), pointer + "/sections")));
}
return result;
}
private Map<String, Map<String, ExperimentDocument.PluginSection>> parsePluginData(
ObjectNode plugins) throws ExperimentDocumentException {
requireCollectionLimit(plugins.size(), "/pluginData");
TreeMap<String, Map<String, ExperimentDocument.PluginSection>> result = new TreeMap<>();
Iterator<Map.Entry<String, JsonNode>> pluginFields = plugins.fields();
while (pluginFields.hasNext()) {
Map.Entry<String, JsonNode> pluginEntry = pluginFields.next();
String pluginId = ExperimentDocument.requireIdentifier(pluginEntry.getKey(), "plugin id");
String pluginPointer = DocumentValueJson.pointer("/pluginData", pluginId);
ObjectNode sections = requireObject(pluginEntry.getValue(), pluginPointer);
requireCollectionLimit(sections.size(), pluginPointer);
TreeMap<String, ExperimentDocument.PluginSection> parsedSections = new TreeMap<>();
Iterator<Map.Entry<String, JsonNode>> sectionFields = sections.fields();
while (sectionFields.hasNext()) {
Map.Entry<String, JsonNode> sectionEntry = sectionFields.next();
String sectionId =
ExperimentDocument.requireIdentifier(sectionEntry.getKey(), "plugin section id");
String sectionPointer = DocumentValueJson.pointer(pluginPointer, sectionId);
ObjectNode section = requireObject(sectionEntry.getValue(), sectionPointer);
rejectUnknown(section, PLUGIN_SECTION_FIELDS, sectionPointer);
parsedSections.put(
sectionId,
new ExperimentDocument.PluginSection(
requiredPositiveInt(section, "schemaVersion", sectionPointer + "/schemaVersion"),
requiredText(section, "algorithmVersion", sectionPointer + "/algorithmVersion"),
DocumentValueJson.fromJson(
requiredNode(section, "data", sectionPointer + "/data"))));
}
result.put(pluginId, parsedSections);
}
return result;
}
private List<ExperimentDocument.AssetReference> parseAssets(ArrayNode array)
throws ExperimentDocumentException {
requireCollectionLimit(array.size(), "/assets");
ArrayList<ExperimentDocument.AssetReference> result = new ArrayList<>();
for (int index = 0; index < array.size(); index++) {
String pointer = "/assets/" + index;
ObjectNode item = requireObject(array.get(index), pointer);
rejectUnknown(item, ASSET_FIELDS, pointer);
result.add(
new ExperimentDocument.AssetReference(
requiredText(item, "id", pointer + "/id"),
requiredText(item, "relativePath", pointer + "/relativePath"),
requiredText(item, "mediaType", pointer + "/mediaType"),
requiredNonNegativeLong(item, "sizeBytes", pointer + "/sizeBytes"),
requiredText(item, "sha256", pointer + "/sha256")));
}
return result;
}
private List<ExperimentDocument.OutputRequest> parseOutputs(ArrayNode array)
throws ExperimentDocumentException {
requireCollectionLimit(array.size(), "/outputs");
ArrayList<ExperimentDocument.OutputRequest> result = new ArrayList<>();
for (int index = 0; index < array.size(); index++) {
String pointer = "/outputs/" + index;
ObjectNode item = requireObject(array.get(index), pointer);
rejectUnknown(item, OUTPUT_FIELDS, pointer);
result.add(
new ExperimentDocument.OutputRequest(
requiredText(item, "id", pointer + "/id"),
requiredText(item, "mediaType", pointer + "/mediaType"),
requiredText(item, "baseName", pointer + "/baseName")));
}
return result;
}
private ExperimentDocument.Provenance parseProvenance(ObjectNode node)
throws ExperimentDocumentException {
rejectUnknown(node, PROVENANCE_FIELDS, "/provenance");
return new ExperimentDocument.Provenance(
requiredText(node, "creatorDisplayName", "/provenance/creatorDisplayName"),
optionalText(node, "verifiedAccount", "/provenance/verifiedAccount"),
parseInstant(
requiredText(node, "createdAt", "/provenance/createdAt"), "/provenance/createdAt"),
parseInstant(
requiredText(node, "modifiedAt", "/provenance/modifiedAt"), "/provenance/modifiedAt"),
requiredText(node, "softwareVersion", "/provenance/softwareVersion"),
requiredText(node, "canonicalSha256", "/provenance/canonicalSha256"),
textArray(
requiredArray(node, "migrationNotes", "/provenance/migrationNotes"),
"/provenance/migrationNotes"));
}
private ExperimentDocument normalizeWorkflow(ExperimentDocument document, boolean verifyHash)
throws ExperimentDocumentException {
ExperimentDocument.WorkflowPayload payload = document.workflow();
if (!ExperimentDocumentFormat.WORKFLOW_FORMAT_ID.equals(payload.format())
|| payload.formatVersion() != ExperimentDocumentFormat.WORKFLOW_VERSION) {
throw failure("/workflow", "unsupported-workflow", "Unsupported embedded workflow format");
}
Workflow workflow;
try {
workflow = workflowParser.parse(payload.content());
} catch (RuntimeException exception) {
throw failure(
"/workflow/content", "invalid-workflow", "Embedded workflow DSL is invalid", exception);
}
String canonical = workflowSerializer.serialize(workflow);
String hash = DocumentHashes.sha256(canonical);
if (verifyHash && !hash.equals(payload.sha256())) {
throw failure(
"/workflow/sha256",
"workflow-hash-mismatch",
"Workflow SHA-256 does not match canonical DSL");
}
ExperimentDocument.WorkflowPayload normalizedPayload =
new ExperimentDocument.WorkflowPayload(
ExperimentDocumentFormat.WORKFLOW_FORMAT_ID,
ExperimentDocumentFormat.WORKFLOW_VERSION,
canonical,
hash);
return new ExperimentDocument(
document.schema(),
document.format(),
document.formatVersion(),
document.experiment(),
normalizedPayload,
document.profiles(),
document.requiredPlugins(),
document.pluginData(),
document.assets(),
document.outputs(),
document.provenance());
}
private ExperimentDocument withCanonicalHash(ExperimentDocument document, String hash) {
ExperimentDocument.Provenance current = document.provenance();
ExperimentDocument.Provenance provenance =
new ExperimentDocument.Provenance(
current.creatorDisplayName(),
current.verifiedAccount(),
current.createdAt(),
current.modifiedAt(),
current.softwareVersion(),
hash,
current.migrationNotes());
return new ExperimentDocument(
document.schema(),
document.format(),
document.formatVersion(),
document.experiment(),
document.workflow(),
document.profiles(),
document.requiredPlugins(),
document.pluginData(),
document.assets(),
document.outputs(),
provenance);
}
private ObjectNode toTree(ExperimentDocument document, boolean includeHash) {
ObjectNode root = JsonNodeFactory.instance.objectNode();
root.put("$schema", document.schema());
root.put("format", document.format());
root.put("formatVersion", document.formatVersion());
root.set("experiment", experimentNode(document.experiment()));
root.set("workflow", workflowNode(document.workflow()));
root.set("profiles", DocumentValueJson.toJson(document.profiles()));
root.set("requiredPlugins", requirementsNode(document.requiredPlugins()));
root.set("pluginData", pluginDataNode(document.pluginData()));
root.set("assets", assetsNode(document.assets()));
root.set("outputs", outputsNode(document.outputs()));
root.set("provenance", provenanceNode(document.provenance(), includeHash));
return root;
}
private ObjectNode experimentNode(ExperimentDocument.ExperimentInfo value) {
ObjectNode node = JsonNodeFactory.instance.objectNode();
node.put("id", value.id());
node.put("name", value.name());
node.put("description", value.description());
node.set("tags", stringArray(value.tags()));
node.put("intent", value.intent());
node.put("sourceMode", value.sourceMode());
if (value.intendedDuration() == null) {
node.putNull("intendedDuration");
} else {
node.put("intendedDuration", value.intendedDuration().toString());
}
node.put("applicationVersion", value.applicationVersion());
return node;
}
private ObjectNode workflowNode(ExperimentDocument.WorkflowPayload value) {
ObjectNode node = JsonNodeFactory.instance.objectNode();
node.put("format", value.format());
node.put("formatVersion", value.formatVersion());
node.put("content", value.content());
node.put("sha256", value.sha256());
return node;
}
private ArrayNode requirementsNode(List<ExperimentDocument.PluginRequirement> values) {
ArrayNode result = JsonNodeFactory.instance.arrayNode();
values.stream()
.sorted(java.util.Comparator.comparing(ExperimentDocument.PluginRequirement::id))
.forEach(
value -> {
ObjectNode node = JsonNodeFactory.instance.objectNode();
node.put("id", value.id());
node.put("versionRange", value.versionRange());
node.set("sections", stringArray(value.sections()));
result.add(node);
});
return result;
}
private ObjectNode pluginDataNode(
Map<String, Map<String, ExperimentDocument.PluginSection>> values) {
ObjectNode plugins = JsonNodeFactory.instance.objectNode();
new TreeMap<>(values)
.forEach(
(pluginId, sections) -> {
ObjectNode sectionNode = JsonNodeFactory.instance.objectNode();
new TreeMap<>(sections)
.forEach(
(sectionId, value) -> {
ObjectNode node = JsonNodeFactory.instance.objectNode();
node.put("schemaVersion", value.schemaVersion());
node.put("algorithmVersion", value.algorithmVersion());
node.set("data", DocumentValueJson.toJson(value.data()));
sectionNode.set(sectionId, node);
});
plugins.set(pluginId, sectionNode);
});
return plugins;
}
private ArrayNode assetsNode(List<ExperimentDocument.AssetReference> values) {
ArrayNode result = JsonNodeFactory.instance.arrayNode();
values.stream()
.sorted(java.util.Comparator.comparing(ExperimentDocument.AssetReference::id))
.forEach(
value -> {
ObjectNode node = JsonNodeFactory.instance.objectNode();
node.put("id", value.id());
node.put("relativePath", value.relativePath());
node.put("mediaType", value.mediaType());
node.put("sizeBytes", value.sizeBytes());
node.put("sha256", value.sha256());
result.add(node);
});
return result;
}
private ArrayNode outputsNode(List<ExperimentDocument.OutputRequest> values) {
ArrayNode result = JsonNodeFactory.instance.arrayNode();
values.stream()
.sorted(java.util.Comparator.comparing(ExperimentDocument.OutputRequest::id))
.forEach(
value -> {
ObjectNode node = JsonNodeFactory.instance.objectNode();
node.put("id", value.id());
node.put("mediaType", value.mediaType());
node.put("baseName", value.baseName());
result.add(node);
});
return result;
}
private ObjectNode provenanceNode(ExperimentDocument.Provenance value, boolean includeHash) {
ObjectNode node = JsonNodeFactory.instance.objectNode();
node.put("creatorDisplayName", value.creatorDisplayName());
node.put("verifiedAccount", value.verifiedAccount());
node.put("createdAt", value.createdAt().toString());
node.put("modifiedAt", value.modifiedAt().toString());
node.put("softwareVersion", value.softwareVersion());
if (includeHash) {
node.put("canonicalSha256", value.canonicalSha256());
}
node.set("migrationNotes", stringArray(value.migrationNotes()));
return node;
}
private ArrayNode stringArray(List<String> values) {
ArrayNode array = JsonNodeFactory.instance.arrayNode();
values.forEach(array::add);
return array;
}
private byte[] writeTree(JsonNode tree) throws ExperimentDocumentException {
try {
return mapper.writeValueAsBytes(tree);
} catch (IOException exception) {
throw failure(
"/",
"serialization-failed",
"Could not serialize canonical experiment document",
exception);
}
}
private static ObjectNode requiredObject(ObjectNode parent, String field, String pointer)
throws ExperimentDocumentException {
return requireObject(requiredNode(parent, field, pointer), pointer);
}
private static ObjectNode requireObject(JsonNode value, String pointer)
throws ExperimentDocumentException {
if (value instanceof ObjectNode objectNode) {
return objectNode;
}
throw failure(pointer, "expected-object", "Expected a JSON object");
}
private static ArrayNode requiredArray(ObjectNode parent, String field, String pointer)
throws ExperimentDocumentException {
JsonNode value = requiredNode(parent, field, pointer);
if (value instanceof ArrayNode arrayNode) {
return arrayNode;
}
throw failure(pointer, "expected-array", "Expected a JSON array");
}
private static JsonNode requiredNode(ObjectNode parent, String field, String pointer)
throws ExperimentDocumentException {
JsonNode value = parent.get(field);
if (value == null) {
throw failure(pointer, "missing-field", "Missing required field: " + field);
}
return value;
}
private static String requiredText(ObjectNode parent, String field, String pointer)
throws ExperimentDocumentException {
JsonNode value = requiredNode(parent, field, pointer);
if (!value.isTextual()) {
throw failure(pointer, "expected-string", "Expected a JSON string");
}
String text = value.textValue();
if (text.length() > ExperimentDocumentFormat.MAX_STRING_LENGTH) {
throw failure(pointer, "max-string", "String exceeds the configured limit");
}
return text;
}
private static String optionalText(ObjectNode parent, String field, String pointer)
throws ExperimentDocumentException {
JsonNode value = parent.get(field);
if (value == null || value.isNull()) {
return "";
}
if (!value.isTextual()) {
throw failure(pointer, "expected-string", "Expected a JSON string or null");
}
String text = value.textValue();
if (text.length() > ExperimentDocumentFormat.MAX_STRING_LENGTH) {
throw failure(pointer, "max-string", "String exceeds the configured limit");
}
return text;
}
private static int requiredPositiveInt(ObjectNode parent, String field, String pointer)
throws ExperimentDocumentException {
JsonNode value = requiredNode(parent, field, pointer);
if (!value.canConvertToInt() || value.intValue() < 1) {
throw failure(pointer, "expected-positive-integer", "Expected a positive integer");
}
return value.intValue();
}
private static long requiredNonNegativeLong(ObjectNode parent, String field, String pointer)
throws ExperimentDocumentException {
JsonNode value = requiredNode(parent, field, pointer);
if (!value.canConvertToLong() || value.longValue() < 0L) {
throw failure(pointer, "expected-nonnegative-integer", "Expected a non-negative integer");
}
return value.longValue();
}
private static List<String> textArray(ArrayNode array, String pointer)
throws ExperimentDocumentException {
requireCollectionLimit(array.size(), pointer);
ArrayList<String> result = new ArrayList<>();
for (int index = 0; index < array.size(); index++) {
JsonNode value = array.get(index);
if (!value.isTextual()) {
throw failure(pointer + "/" + index, "expected-string", "Expected a JSON string");
}
String text = value.textValue();
if (text.length() > ExperimentDocumentFormat.MAX_STRING_LENGTH) {
throw failure(pointer + "/" + index, "max-string", "String exceeds the configured limit");
}
result.add(text);
}
return result;
}
private static void rejectUnknown(ObjectNode node, Set<String> allowed, String pointer)
throws ExperimentDocumentException {
HashSet<String> unknown = new HashSet<>();
node.fieldNames()
.forEachRemaining(
name -> {
if (!allowed.contains(name)) {
unknown.add(name);
}
});
if (!unknown.isEmpty()) {
String field = unknown.stream().sorted().findFirst().orElseThrow();
throw failure(
DocumentValueJson.pointer(pointer, field),
"unknown-field",
"Unknown core field: " + field);
}
}
private static void requireCollectionLimit(int size, String pointer)
throws ExperimentDocumentException {
if (size > ExperimentDocumentFormat.MAX_COLLECTION_SIZE) {
throw failure(pointer, "max-collection", "Collection exceeds the configured limit");
}
}
private static Duration parseDuration(String value, String pointer)
throws ExperimentDocumentException {
try {
return Duration.parse(value);
} catch (DateTimeParseException exception) {
throw failure(pointer, "invalid-duration", "Expected an ISO-8601 duration", exception);
}
}
private static Instant parseInstant(String value, String pointer)
throws ExperimentDocumentException {
try {
return Instant.parse(value);
} catch (DateTimeParseException exception) {
throw failure(pointer, "invalid-instant", "Expected an ISO-8601 instant", exception);
}
}
private static ExperimentDocumentException failure(String pointer, String code, String message) {
return new ExperimentDocumentException(pointer, code, message);
}
private static ExperimentDocumentException failure(
String pointer, String code, String message, Throwable cause) {
return new ExperimentDocumentException(pointer, code, message, cause);
}
}