FeedTomlSchemaValidator.java
package net.sasasin.sreader.service.feed.toml;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalInt;
import org.tomlj.TomlArray;
import org.tomlj.TomlParseResult;
import org.tomlj.TomlPosition;
import org.tomlj.TomlTable;
/**
* Validates sreader feed-file shape and TOML value types on a TomlJ tree. URL normalization, enum
* conversion, and ImportFeed construction are left to {@link FeedTomlImportMapper}.
*
* <p>Unknown root/feed keys are intentionally accepted for wire compatibility; the writer never
* re-emits them. {@code generated_at} is optional and unused by import. {@code unsubscribed_at}
* remains a quoted string for compatibility (native TOML datetime is rejected).
*
* <p>When TomlJ returns a partial tree after syntax recovery, only keys that can be read safely are
* validated. Missing-field checks run when a feed table itself recovered; cascade errors for
* unreadable nodes are skipped so syntax diagnostics remain primary.
*/
final class FeedTomlSchemaValidator {
record Result(OptionalInt schemaVersion, List<FeedTomlEntry> feeds, List<FeedTomlIssue> issues) {
Result {
feeds = List.copyOf(Objects.requireNonNull(feeds, "feeds"));
issues = List.copyOf(Objects.requireNonNull(issues, "issues"));
}
}
Result validate(TomlParseResult document) {
Objects.requireNonNull(document, "document must not be null");
List<FeedTomlIssue> issues = new ArrayList<>();
OptionalInt schemaVersion = readSchemaVersion(document, issues);
List<FeedTomlEntry> feeds = new ArrayList<>();
readFeeds(document, feeds, issues);
return new Result(schemaVersion, feeds, issues);
}
private OptionalInt readSchemaVersion(TomlParseResult document, List<FeedTomlIssue> issues) {
if (!document.contains("schema_version")) {
issues.add(
FeedTomlIssue.schema(
FeedTomlPosition.of(1, 1),
"schema_version",
"schema_version is required",
issues.size()));
return OptionalInt.empty();
}
FeedTomlPosition position = positionOf(document, "schema_version");
if (!document.isLong("schema_version")) {
issues.add(
FeedTomlIssue.schema(
position, "schema_version", "schema_version must be an integer", issues.size()));
return OptionalInt.empty();
}
long value = document.getLong("schema_version");
if (value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) {
issues.add(
FeedTomlIssue.schema(
position,
"schema_version",
"schema_version is out of range for a 32-bit integer: " + value,
issues.size()));
return OptionalInt.empty();
}
return OptionalInt.of((int) value);
}
private void readFeeds(
TomlParseResult document, List<FeedTomlEntry> feeds, List<FeedTomlIssue> issues) {
if (!document.contains("feeds")) {
return;
}
if (!document.isArray("feeds")) {
issues.add(
FeedTomlIssue.schema(
positionOf(document, "feeds"),
"feeds",
"feeds must be an array of tables",
issues.size()));
return;
}
TomlArray array = document.getArray("feeds");
if (array == null) {
return;
}
for (int i = 0; i < array.size(); i++) {
readFeed(array, i, feeds, issues);
}
}
private void readFeed(
TomlArray array, int index, List<FeedTomlEntry> feeds, List<FeedTomlIssue> issues) {
String pathPrefix = "feeds[" + (index + 1) + "]";
Object raw = array.get(index);
if (!(raw instanceof TomlTable table)) {
issues.add(
FeedTomlIssue.schema(
FeedTomlPosition.unknown(),
pathPrefix,
pathPrefix + " must be a table",
issues.size()));
return;
}
FeedTomlPosition tablePosition = positionOf(table);
Optional<String> url = Optional.empty();
FeedTomlPosition urlPosition = tablePosition;
if (!table.contains("url")) {
issues.add(
FeedTomlIssue.schema(
tablePosition, pathPrefix + ".url", pathPrefix + ".url is required", issues.size()));
} else if (!table.isString("url")) {
issues.add(
FeedTomlIssue.schema(
positionOf(table, "url"),
pathPrefix + ".url",
pathPrefix + ".url must be a string",
issues.size()));
} else {
url = Optional.ofNullable(table.getString("url"));
urlPosition = positionOf(table, "url");
}
Optional<String> status = optionalString(table, "status", pathPrefix, issues);
Optional<String> reason = optionalString(table, "unsubscribe_reason", pathPrefix, issues);
Optional<String> unsubscribedAt = optionalUnsubscribedAt(table, pathPrefix, issues);
Optional<String> note = optionalString(table, "note", pathPrefix, issues);
Optional<String> method = optionalString(table, "full_text_method", pathPrefix, issues);
feeds.add(
new FeedTomlEntry(
index,
url,
status,
reason,
unsubscribedAt,
note,
method,
tablePosition,
urlPosition,
fieldPositions(table)));
}
private static Map<String, FeedTomlPosition> fieldPositions(TomlTable table) {
return table.keySet().stream()
.collect(
java.util.stream.Collectors.toUnmodifiableMap(
key -> key, key -> positionOf(table, key)));
}
private Optional<String> optionalUnsubscribedAt(
TomlTable table, String pathPrefix, List<FeedTomlIssue> issues) {
if (!table.contains("unsubscribed_at")) {
return Optional.empty();
}
String fieldPath = pathPrefix + ".unsubscribed_at";
// Compatibility: accept only TOML string, not native datetime values.
if (!table.isString("unsubscribed_at")) {
issues.add(
FeedTomlIssue.schema(
positionOf(table, "unsubscribed_at"),
fieldPath,
fieldPath + " must be a string",
issues.size()));
return Optional.empty();
}
return Optional.ofNullable(table.getString("unsubscribed_at"));
}
private Optional<String> optionalString(
TomlTable table, String key, String pathPrefix, List<FeedTomlIssue> issues) {
if (!table.contains(key)) {
return Optional.empty();
}
String fieldPath = pathPrefix + "." + key;
if (!table.isString(key)) {
issues.add(
FeedTomlIssue.schema(
positionOf(table, key), fieldPath, fieldPath + " must be a string", issues.size()));
return Optional.empty();
}
return Optional.ofNullable(table.getString(key));
}
private static FeedTomlPosition positionOf(TomlTable table, String key) {
TomlPosition position = table.inputPositionOf(key);
if (position != null) {
return FeedTomlPosition.of(position.line(), position.column());
}
return positionOf(table);
}
private static FeedTomlPosition positionOf(TomlTable table) {
for (String key : table.keySet()) {
TomlPosition position = table.inputPositionOf(key);
if (position != null) {
return FeedTomlPosition.of(position.line(), position.column());
}
}
return FeedTomlPosition.unknown();
}
}