FieldOverrideService.java
package info.textgrid.services.textapi;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator;
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.Config;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import info.textgrid.services.textapi.apiv2.Manifest;
import info.textgrid.services.textapi.apiv2.Item;
/**
* Service for applying field overrides based on YAML configuration files.
* Configuration files are named after the projectId and located in a configurable directory.
*/
@ApplicationScoped
public class FieldOverrideService {
private static final String DEFAULT_CONFIG_DIR = "config/overrides";
private String configDir = "config/overrides";
private YAMLMapper yamlMapper;
@Inject
Config config;
@PostConstruct
public void init() {
// Read config directory from config property if available
String configDirValue = config.getOptionalValue("field.overrides.config-dir", String.class).orElse("config/overrides");
this.configDir = configDirValue;
// Ensure config directory exists
Path dirPath = Paths.get(configDir);
try {
if (!Files.exists(dirPath)) {
Files.createDirectories(dirPath);
}
} catch (IOException e) {
throw new RuntimeException("Failed to create config directory: " + configDir, e);
}
// Initialize YAML mapper
YAMLFactory yamlFactory = YAMLFactory.builder()
.enable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER)
.build();
this.yamlMapper = new YAMLMapper(yamlFactory);
// Allow scalar values (e.g. a single target URL) to be deserialized into lists
this.yamlMapper.enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY);
}
/**
* Extract the identifying segment (last non-empty path segment) from an ID or URL.
* E.g. "https://host/v2/collections/textgrid:48vn1.3/" -> "textgrid:48vn1.3"
* or "textgrid:48vn1.3" -> "textgrid:48vn1.3".
* This makes matching independent of host names and trailing slashes.
*/
private static String idSegment(String idOrUrl) {
if (idOrUrl == null) {
return null;
}
String trimmed = idOrUrl.trim();
while (trimmed.endsWith("/")) {
trimmed = trimmed.substring(0, trimmed.length() - 1);
}
int slash = trimmed.lastIndexOf('/');
return slash >= 0 ? trimmed.substring(slash + 1) : trimmed;
}
/**
* Check whether an entry satisfies the "include" rules for the resource with the
* given ID. When an include rule matches, only entries whose ID appears in the
* rule's "target" list are retained; all others are filtered out.
*
* Matching is done on the identifying last path segment of the IDs/URLs, so
* host names and trailing slashes do not matter.
*
* @param config the configuration containing the include rules
* @param currentResourceId the ID of the resource whose list is being built
* @param entryIdOrUrl the list entry candidate (ID or URL)
* @return true if the entry satisfies the include rules; also returns true
* when no include rule applies to currentResourceId (include does not imply deny-by-default)
*/
public boolean isIncluded(ProjectFieldConfig config, String currentResourceId, String entryIdOrUrl) {
if (config == null || config.getInclude() == null
|| currentResourceId == null || entryIdOrUrl == null) {
return true;
}
String currentSegment = idSegment(currentResourceId);
String entrySegment = idSegment(entryIdOrUrl);
if (currentSegment == null || entrySegment == null) {
return true;
}
for (Include rule : config.getInclude()) {
if (rule == null || rule.getIn() == null) {
continue;
}
// The rule only applies to the resource identified by "in"
if (!currentSegment.equals(idSegment(rule.getIn()))) {
continue;
}
if (rule.getTarget() == null) {
continue;
}
// An entry is included if it matches any target in the rule
for (String target : rule.getTarget()) {
if (target != null && entrySegment.equals(idSegment(target))) {
return true;
}
}
// If a rule applies to this resource but the entry is not in any target, it is excluded
return false;
}
// No include rule applies; entry is included
return true;
}
/**
* Check whether an entry should be removed from the list of the resource with the
* given ID, according to the "exclude" rules in the configuration.
*
* Matching is done on the identifying last path segment of the IDs/URLs, so
* host names and trailing slashes do not matter.
*
* @param config the configuration containing the exclude rules
* @param currentResourceId the ID of the resource whose list is being built
* @param entryIdOrUrl the list entry candidate (ID or URL)
* @return true if the entry must be removed from the list
*/
public boolean isExcluded(ProjectFieldConfig config, String currentResourceId, String entryIdOrUrl) {
if (config == null || config.getExclude() == null
|| currentResourceId == null || entryIdOrUrl == null) {
return false;
}
String currentSegment = idSegment(currentResourceId);
String entrySegment = idSegment(entryIdOrUrl);
if (currentSegment == null || entrySegment == null) {
return false;
}
for (Exclude rule : config.getExclude()) {
if (rule == null || rule.getFrom() == null) {
continue;
}
// The rule only applies to the resource identified by "from"
if (!currentSegment.equals(idSegment(rule.getFrom()))) {
continue;
}
if (rule.getTarget() == null) {
continue;
}
for (String target : rule.getTarget()) {
if (target != null && entrySegment.equals(idSegment(target))) {
return true;
}
}
}
return false;
}
/**
* For the given resource ID, retrieve the full target URLs from any include rule.
* Returns the list in the exact order provided in the configuration; null if
* no include rule applies.
*/
public List<String> getIncludeTargetUrls(ProjectFieldConfig config, String currentResourceId) {
if (config == null || config.getInclude() == null || currentResourceId == null) {
return null;
}
String currentSegment = idSegment(currentResourceId);
if (currentSegment == null) {
return null;
}
for (Include rule : config.getInclude()) {
if (rule == null || rule.getIn() == null) {
continue;
}
if (!currentSegment.equals(idSegment(rule.getIn()))) {
continue;
}
return rule.getTarget(); // preserve order from config
}
return null;
}
/**
* Load the YAML configuration for a specific project.
*
* @param projectId the project ID
* @return the configuration or null if no config exists for this project
*/
public ProjectFieldConfig loadConfig(String projectId) {
String yamlFileName = projectId + ".yaml";
Path configPath = Paths.get(configDir, yamlFileName);
if (!Files.exists(configPath)) {
return null;
}
try {
return yamlMapper.readValue(configPath.toFile(), ProjectFieldConfig.class);
} catch (IOException e) {
throw new RuntimeException("Failed to load field override config for project: " + projectId, e);
}
}
/**
* Apply all field overrides to a manifest object.
*
* @param config the configuration containing override rules
* @param manifest the manifest to modify
* @param contextSlug the context slug (object name) for scope limiting
* @return true if any changes were made
*/
public boolean applyManifestOverrides(ProjectFieldConfig config, Manifest manifest, String contextSlug) {
if (config == null || config.getFields() == null || config.getFields().isEmpty()) {
return false;
}
boolean changed = false;
// Check scope restrictions
if (!shouldApplyToScope(config, contextSlug)) {
return false;
}
// Apply overrides to manifest fields
if (applyOverrideToMap(manifest, config)) {
changed = true;
}
// Apply overrides to items
if (manifest.getItems() != null) {
for (Item item : manifest.getItems()) {
if (applyItemOverrides(config, item, contextSlug)) {
changed = true;
}
}
}
return changed;
}
/**
* Apply all field overrides to an item object.
*
* @param config the configuration containing override rules
* @param item the item to modify
* @param contextSlug the context slug (object name) for scope limiting
* @return true if any changes were made
*/
public boolean applyItemOverrides(ProjectFieldConfig config, Item item, String contextSlug) {
if (config == null || config.getFields() == null || config.getFields().isEmpty()) {
return false;
}
if (!shouldApplyToScope(config, contextSlug)) {
return false;
}
return applyOverrideToMap(item, config);
}
/**
* Check if the override should be applied based on scope restrictions.
*/
private boolean shouldApplyToScope(ProjectFieldConfig config, String contextSlug) {
if (contextSlug == null || contextSlug.isEmpty()) {
return true;
}
List<String> applyOnlyTo = config.getApplyOnlyTo();
List<String> excludeFrom = config.getExcludeFrom();
// If applyOnlyTo is specified, only apply to those slugs
if (applyOnlyTo != null && !applyOnlyTo.isEmpty()) {
return applyOnlyTo.contains(contextSlug);
}
// If excludeFrom is specified, don't apply to those slugs
if (excludeFrom != null && !excludeFrom.isEmpty()) {
return !excludeFrom.contains(contextSlug);
}
// No restrictions, apply to all
return true;
}
/**
* Apply field overrides to a Java object (Manifest, Item, etc.).
*/
private boolean applyOverrideToMap(Object target, ProjectFieldConfig config) {
boolean changed = false;
for (FieldOverride override : config.getFields()) {
if (applySingleOverride(target, override)) {
changed = true;
}
}
return changed;
}
/**
* Apply a single override to a Java object field.
* Supports nested paths like "contents.id" or "contents[0].id"
*/
private boolean applySingleOverride(Object target, FieldOverride override) {
if (target == null) {
return false;
}
String fieldPath = override.getField();
// Check if the field path contains dots (nested path)
if (fieldPath.contains(".")) {
String[] pathSegments = fieldPath.split("\\.");
return applyNestedOverride(target, override, pathSegments);
}
// Check if the field path contains array index notation
if (fieldPath.matches(".*\\[\\d+\\].*")) {
String[] pathSegments = fieldPath.split("\\.");
return applyNestedOverride(target, override, pathSegments);
}
// Simple field - use the original logic
return applySimpleFieldOverride(target, override);
}
/**
* Find a field by name in a class (including superclasses).
*/
private java.lang.reflect.Field findField(Class<?> clazz, String fieldName) {
while (clazz != null) {
try {
return clazz.getDeclaredField(fieldName);
} catch (NoSuchFieldException e) {
clazz = clazz.getSuperclass();
}
}
return null;
}
/**
* Load and apply overrides for a project in one call.
*
* @param projectId the project ID
* @param manifest the manifest to modify
* @param contextSlug the context slug for scope limiting
* @return true if any changes were made
*/
public boolean loadAndApplyOverrides(String projectId, Manifest manifest, String contextSlug) {
ProjectFieldConfig config = loadConfig(projectId);
if (config == null) {
return false;
}
return applyManifestOverrides(config, manifest, contextSlug);
}
/**
* Load and apply overrides to an item in one call.
*
* @param projectId the project ID
* @param item the item to modify
* @param contextSlug the context slug for scope limiting
* @return true if any changes were made
*/
public boolean loadAndApplyItemOverrides(String projectId, Item item, String contextSlug) {
ProjectFieldConfig config = loadConfig(projectId);
if (config == null) {
return false;
}
return applyItemOverrides(config, item, contextSlug);
}
/**
* Apply override to a nested field path (e.g., "contents.id" or "contents[0].id").
*/
private boolean applyNestedOverride(Object target, FieldOverride override, String[] pathSegments) {
return applyNestedOverrideRecursive(target, override, pathSegments, 0);
}
/**
* Recursively apply override to nested field path.
*/
private boolean applyNestedOverrideRecursive(Object target, FieldOverride override, String[] pathSegments, int currentIndex) {
if (target == null || currentIndex >= pathSegments.length) {
return false;
}
// If this is the last segment, apply the override
if (currentIndex == pathSegments.length - 1) {
String finalField = pathSegments[currentIndex];
// Remove array index if present from the final field
if (finalField.matches(".*\\[\\d+\\]")) {
finalField = finalField.substring(0, finalField.indexOf('['));
}
FieldOverride finalOverride = new FieldOverride();
finalOverride.setField(finalField);
finalOverride.setType(override.getType());
finalOverride.setValue(override.getValue());
finalOverride.setPattern(override.getPattern());
finalOverride.setReplacement(override.getReplacement());
return applySimpleFieldOverride(target, finalOverride);
}
// Navigate to the next level
String segment = pathSegments[currentIndex];
Object next = navigateToField(target, segment);
// Special case: if we navigated to a List and didn't use array index notation
// apply the override to all items in the list
if (next instanceof List && !segment.matches(".*\\[\\d+\\]")) {
List<?> list = (List<?>) next;
boolean changed = false;
for (Object item : list) {
if (item != null) {
if (applyNestedOverrideRecursive(item, override, pathSegments, currentIndex + 1)) {
changed = true;
}
}
}
return changed;
}
// Continue recursion
return applyNestedOverrideRecursive(next, override, pathSegments, currentIndex + 1);
}
/**
* Navigate to a field, handling arrays with index notation.
* Returns the target object(s) for applying overrides.
*/
private Object navigateToField(Object obj, String fieldPath) {
if (obj == null) {
return null;
}
// Handle array index notation: "contents[0]" -> field="contents", index=0
String fieldName = fieldPath;
int arrayIndex = -1;
if (fieldPath.matches(".*\\[\\d+\\]")) {
int bracketOpen = fieldPath.indexOf('[');
fieldName = fieldPath.substring(0, bracketOpen);
arrayIndex = Integer.parseInt(fieldPath.substring(bracketOpen + 1, fieldPath.indexOf(']')));
}
try {
java.lang.reflect.Field field = findField(obj.getClass(), fieldName);
if (field == null) {
return null;
}
field.setAccessible(true);
Object value = field.get(obj);
if (arrayIndex >= 0 && value instanceof List) {
List<?> list = (List<?>) value;
if (arrayIndex < list.size()) {
return list.get(arrayIndex);
}
return null;
}
return value;
} catch (IllegalAccessException e) {
return null;
}
}
/**
* Apply override to a simple (flat) field.
*/
private boolean applySimpleFieldOverride(Object target, FieldOverride override) {
if (target == null) {
return false;
}
// Use reflection to access and modify fields
try {
// Get the field by name
java.lang.reflect.Field field = findField(target.getClass(), override.getField());
if (field == null) {
return false;
}
field.setAccessible(true);
Object currentValue = field.get(target);
switch (override.getType()) {
case "override":
// Replace the field value
if (override.getValue() != null) {
field.set(target, override.getValue());
return true;
}
break;
case "append":
// Append to an array/list
if (currentValue instanceof List) {
@SuppressWarnings("unchecked")
java.util.List<Object> list = (java.util.List<Object>) currentValue;
if (override.getValue() != null) {
list.add(override.getValue());
return true;
}
}
break;
case "pattern":
// Apply regex pattern to string value
if (currentValue instanceof String) {
String currentValueStr = (String) currentValue;
String pattern = override.getPattern();
String replacement = override.getReplacement();
if (pattern != null) {
Pattern regex = Pattern.compile(pattern);
Matcher matcher = regex.matcher(currentValueStr);
String newValue = matcher.replaceAll(replacement != null ? replacement : "");
if (!newValue.equals(currentValueStr)) {
field.set(target, newValue);
return true;
}
}
}
break;
}
} catch (IllegalAccessException e) {
// Field access failed
}
return false;
}
}