diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java
index 9d9b979..02cd11d 100644
--- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java
@@ -43,6 +43,8 @@ public class DictionaryRecordService {
private final DictionaryRecordRepository repository;
private final DictionaryDefinitionService definitionService;
private final RecordValidator validator;
+ private final cloud.nstart.terravault.ordinis.restapi.service.reference.ReferenceValidator referenceValidator;
+ private final cloud.nstart.terravault.ordinis.auth.ScopeContext scopeContext;
private final OutboxRecorder outbox;
private final AuditLogger auditLogger;
@@ -52,11 +54,15 @@ public class DictionaryRecordService {
DictionaryRecordRepository repository,
DictionaryDefinitionService definitionService,
RecordValidator validator,
+ cloud.nstart.terravault.ordinis.restapi.service.reference.ReferenceValidator referenceValidator,
+ cloud.nstart.terravault.ordinis.auth.ScopeContext scopeContext,
OutboxRecorder outbox,
AuditLogger auditLogger) {
this.repository = repository;
this.definitionService = definitionService;
this.validator = validator;
+ this.referenceValidator = referenceValidator;
+ this.scopeContext = scopeContext;
this.outbox = outbox;
this.auditLogger = auditLogger;
}
@@ -213,6 +219,15 @@ public class DictionaryRecordService {
private void validate(DictionaryDefinition d, CreateRecordRequest req) {
ValidationResult vr = validator.validate(d, req.data());
if (!vr.valid()) throw new ValidationException(vr.errors());
+
+ // Cross-dictionary x-references — после schema validation, потому что
+ // если schema fail, value может быть not-textual и refs check бросит false-positives.
+ var refErrors = referenceValidator.validate(
+ d.getSchemaJson(),
+ req.data(),
+ scopeContext.currentScopes().stream().toList(),
+ OffsetDateTime.now());
+ if (!refErrors.isEmpty()) throw new ValidationException(refErrors);
}
/**
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/ReferenceValidator.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/ReferenceValidator.java
new file mode 100644
index 0000000..3861f16
--- /dev/null
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/ReferenceValidator.java
@@ -0,0 +1,184 @@
+package cloud.nstart.terravault.ordinis.restapi.service.reference;
+
+import cloud.nstart.terravault.ordinis.domain.DataScope;
+import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
+import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository;
+import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
+import cloud.nstart.terravault.ordinis.validation.ValidationError;
+import com.fasterxml.jackson.databind.JsonNode;
+import org.springframework.stereotype.Component;
+
+import java.time.OffsetDateTime;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Cross-dictionary FK references — v2 feature. JSON Schema extension
+ * {@code x-references: "dict_name.field_name"} объявляет что значение
+ * в этом поле должно ссылаться на active record в указанном словаре,
+ * где {@code data->>field_name} == referencing value.
+ *
+ *
Пример:
+ *
+ * {
+ * "type": "object",
+ * "properties": {
+ * "satelliteTypeCode": {
+ * "type": "string",
+ * "x-references": "satellite_type.code"
+ * }
+ * }
+ * }
+ *
+ *
+ * На POST/PUT record service вызывает {@link #validate} который для
+ * каждого x-references поля делает lookup. Если referenced record не
+ * найден / не активен / не в доступном scope — ValidationError.
+ *
+ *
v1 ограничения:
+ *
+ * - Только top-level fields (nested object refs не поддержаны).
+ * - Только string-valued references (number/array — позже).
+ * - Без cascade на delete/close: если referenced record закрывается,
+ * referencing records остаются с dangling FK. Отдельный alert
+ * будет в v2.5.
+ * - Валидация в момент CRUD, не на schema change. Если меняется
+ * schema с new x-references на existing data — может быть orphan,
+ * проверяем при first re-save.
+ *
+ */
+@Component
+public class ReferenceValidator {
+
+ private final DictionaryDefinitionRepository definitionRepository;
+ private final DictionaryRecordRepository recordRepository;
+
+ public ReferenceValidator(
+ DictionaryDefinitionRepository definitionRepository,
+ DictionaryRecordRepository recordRepository) {
+ this.definitionRepository = definitionRepository;
+ this.recordRepository = recordRepository;
+ }
+
+ /**
+ * Validate references. Возвращает список errors (пусто = OK). Не бросает.
+ * Caller (DictionaryRecordService) кидает ValidationException если errors > 0.
+ *
+ * @param schema JSON Schema справочника (содержит x-references markers)
+ * @param data payload записи
+ * @param allowedScopes scope подмножество которое viewер видит — referenced
+ * records должны быть в нём (если выше — denied)
+ * @param at момент времени (transaction time) — обычно now()
+ */
+ public List validate(
+ JsonNode schema, JsonNode data, List allowedScopes, OffsetDateTime at) {
+ List errors = new ArrayList<>();
+ if (schema == null || data == null || !schema.isObject() || !data.isObject()) {
+ return errors;
+ }
+ JsonNode properties = schema.get("properties");
+ if (properties == null || !properties.isObject()) return errors;
+
+ OffsetDateTime when = at == null ? OffsetDateTime.now() : at;
+
+ Iterator> it = properties.fields();
+ while (it.hasNext()) {
+ Map.Entry entry = it.next();
+ String fieldName = entry.getKey();
+ JsonNode propSchema = entry.getValue();
+ JsonNode refNode = propSchema.get("x-references");
+ if (refNode == null || !refNode.isTextual()) continue;
+
+ String refSpec = refNode.asText();
+ ParsedRef parsed;
+ try {
+ parsed = parseRef(refSpec);
+ } catch (IllegalArgumentException e) {
+ errors.add(new ValidationError(
+ "/" + fieldName,
+ "x_references_malformed",
+ "x-references value malformed: " + e.getMessage()));
+ continue;
+ }
+
+ JsonNode value = data.get(fieldName);
+ if (value == null || value.isNull()) {
+ // Если поле required, JSON Schema валидация уже отлавливает.
+ // Если optional — references skip (нет значения = нет проверки).
+ continue;
+ }
+ if (!value.isTextual()) {
+ errors.add(new ValidationError(
+ "/" + fieldName,
+ "x_references_non_string",
+ "x-references поддерживает только string values в v1, got: " + value.getNodeType()));
+ continue;
+ }
+ String stringValue = value.asText();
+
+ Optional targetOpt =
+ definitionRepository.findByName(parsed.dictionaryName());
+ if (targetOpt.isEmpty()) {
+ errors.add(new ValidationError(
+ "/" + fieldName,
+ "x_references_dict_not_found",
+ "Referenced dictionary '" + parsed.dictionaryName() + "' does not exist"));
+ continue;
+ }
+ DictionaryDefinition target = targetOpt.get();
+
+ if (!allowedScopes.contains(target.getScope())) {
+ // Scope hide: referenced dictionary not visible to caller. Возвращаем
+ // ту же ошибку что и "not found" чтобы не leak'ало existence.
+ errors.add(new ValidationError(
+ "/" + fieldName,
+ "x_references_target_not_found",
+ "Referenced record not found in '" + parsed.dictionaryName()
+ + "' for value '" + stringValue + "'"));
+ continue;
+ }
+
+ List> matches = recordRepository.findActiveByJsonField(
+ target.getId(), when, parsed.fieldName(), stringValue);
+
+ if (matches.isEmpty()) {
+ errors.add(new ValidationError(
+ "/" + fieldName,
+ "x_references_target_not_found",
+ "Referenced record not found in '" + parsed.dictionaryName()
+ + "' for " + parsed.fieldName() + "='" + stringValue + "'"));
+ }
+ // Если matches.size() > 1 — это ситуация когда referenced field не
+ // x-unique. Проходим: matches.first работает. Лог-warning отдельно
+ // (TODO: emit metric nsi_reference_ambiguous_total).
+ }
+ return errors;
+ }
+
+ /**
+ * Parse {@code "dict_name.field"} format. Validates что обе части
+ * непустые и не содержат точек (чтобы избежать unexpected nested paths).
+ */
+ static ParsedRef parseRef(String spec) {
+ if (spec == null || spec.isBlank()) {
+ throw new IllegalArgumentException("empty x-references");
+ }
+ int dot = spec.indexOf('.');
+ if (dot <= 0 || dot == spec.length() - 1) {
+ throw new IllegalArgumentException(
+ "x-references must be 'dict_name.field', got: " + spec);
+ }
+ String dict = spec.substring(0, dot);
+ String field = spec.substring(dot + 1);
+ if (dict.contains(".") || field.contains(".")) {
+ throw new IllegalArgumentException(
+ "x-references nested paths not supported in v1: " + spec);
+ }
+ return new ParsedRef(dict, field);
+ }
+
+ public record ParsedRef(String dictionaryName, String fieldName) {}
+}
diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordServiceTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordServiceTest.java
index 96db241..f4b6da0 100644
--- a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordServiceTest.java
+++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordServiceTest.java
@@ -38,7 +38,7 @@ class DictionaryRecordServiceTest {
repo = mock(DictionaryRecordRepository.class);
// resolveBusinessKey/enforceUniqueFields не дёргают остальные деп — передаём null,
// mocking concrete Spring services через Mockito-inline на JDK 25 ломается.
- service = new DictionaryRecordService(repo, null, null, null, null);
+ service = new DictionaryRecordService(repo, null, null, null, null, null, null);
}
// ============= resolveBusinessKey =============
diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/ReferenceValidatorTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/ReferenceValidatorTest.java
new file mode 100644
index 0000000..b7fa05b
--- /dev/null
+++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/ReferenceValidatorTest.java
@@ -0,0 +1,253 @@
+package cloud.nstart.terravault.ordinis.restapi.service.reference;
+
+import cloud.nstart.terravault.ordinis.domain.DataScope;
+import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
+import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository;
+import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord;
+import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
+import cloud.nstart.terravault.ordinis.validation.ValidationError;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class ReferenceValidatorTest {
+
+ private static final ObjectMapper OM = new ObjectMapper();
+
+ private DictionaryDefinitionRepository defRepo;
+ private DictionaryRecordRepository recRepo;
+ private ReferenceValidator validator;
+
+ @BeforeEach
+ void setUp() {
+ defRepo = mock(DictionaryDefinitionRepository.class);
+ recRepo = mock(DictionaryRecordRepository.class);
+ validator = new ReferenceValidator(defRepo, recRepo);
+ }
+
+ // === parseRef static tests ===
+
+ @Test
+ void parseRefAcceptsValid() {
+ var p = ReferenceValidator.parseRef("satellite_type.code");
+ assertThat(p.dictionaryName()).isEqualTo("satellite_type");
+ assertThat(p.fieldName()).isEqualTo("code");
+ }
+
+ @Test
+ void parseRefRejectsEmpty() {
+ assertThatThrownBy(() -> ReferenceValidator.parseRef(""))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> ReferenceValidator.parseRef(null))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void parseRefRejectsMissingDot() {
+ assertThatThrownBy(() -> ReferenceValidator.parseRef("noDot"))
+ .hasMessageContaining("dict_name.field");
+ }
+
+ @Test
+ void parseRefRejectsTrailingDot() {
+ assertThatThrownBy(() -> ReferenceValidator.parseRef("dict."))
+ .hasMessageContaining("dict_name.field");
+ assertThatThrownBy(() -> ReferenceValidator.parseRef(".field"))
+ .hasMessageContaining("dict_name.field");
+ }
+
+ @Test
+ void parseRefRejectsNestedPaths() {
+ assertThatThrownBy(() -> ReferenceValidator.parseRef("a.b.c"))
+ .hasMessageContaining("nested paths not supported");
+ }
+
+ // === validate() tests ===
+
+ @Test
+ void schemaWithoutReferences_noErrors() throws Exception {
+ JsonNode schema = OM.readTree("""
+ {"type":"object","properties":{"name":{"type":"string"}}}""");
+ JsonNode data = OM.readTree("{\"name\":\"foo\"}");
+
+ var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
+ assertThat(errors).isEmpty();
+ }
+
+ @Test
+ void missingValue_skippedSilently() throws Exception {
+ JsonNode schema = OM.readTree("""
+ {"type":"object","properties":{
+ "ref":{"type":"string","x-references":"satellite_type.code"}
+ }}""");
+ JsonNode data = OM.readTree("{\"name\":\"foo\"}"); // нет 'ref'
+
+ var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
+ assertThat(errors).isEmpty();
+ }
+
+ @Test
+ void existingReferencedRecord_noErrors() throws Exception {
+ UUID targetId = UUID.randomUUID();
+ DictionaryDefinition target = mockDefinition("satellite_type", DataScope.PUBLIC, targetId);
+ when(defRepo.findByName("satellite_type")).thenReturn(Optional.of(target));
+ when(recRepo.findActiveByJsonField(eq(targetId), any(), eq("code"), eq("LEO-001")))
+ .thenReturn(List.of(stubRecord()));
+
+ JsonNode schema = OM.readTree("""
+ {"type":"object","properties":{
+ "satTypeCode":{"type":"string","x-references":"satellite_type.code"}
+ }}""");
+ JsonNode data = OM.readTree("{\"satTypeCode\":\"LEO-001\"}");
+
+ var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
+ assertThat(errors).isEmpty();
+ }
+
+ @Test
+ void missingReferencedRecord_returnsError() throws Exception {
+ UUID targetId = UUID.randomUUID();
+ DictionaryDefinition target = mockDefinition("satellite_type", DataScope.PUBLIC, targetId);
+ when(defRepo.findByName("satellite_type")).thenReturn(Optional.of(target));
+ when(recRepo.findActiveByJsonField(any(), any(), anyString(), anyString()))
+ .thenReturn(List.of()); // no matches
+
+ JsonNode schema = OM.readTree("""
+ {"type":"object","properties":{
+ "satTypeCode":{"type":"string","x-references":"satellite_type.code"}
+ }}""");
+ JsonNode data = OM.readTree("{\"satTypeCode\":\"NONEXISTENT\"}");
+
+ var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0).code()).isEqualTo("x_references_target_not_found");
+ assertThat(errors.get(0).message()).contains("satellite_type");
+ assertThat(errors.get(0).message()).contains("NONEXISTENT");
+ }
+
+ @Test
+ void missingReferencedDictionary_returnsError() throws Exception {
+ when(defRepo.findByName("nonexistent_dict")).thenReturn(Optional.empty());
+
+ JsonNode schema = OM.readTree("""
+ {"type":"object","properties":{
+ "ref":{"type":"string","x-references":"nonexistent_dict.code"}
+ }}""");
+ JsonNode data = OM.readTree("{\"ref\":\"any\"}");
+
+ var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0).code()).isEqualTo("x_references_dict_not_found");
+ }
+
+ @Test
+ void targetScopeNotAccessible_returnsNotFoundError() throws Exception {
+ // Caller имеет PUBLIC scope, target dict — INTERNAL.
+ // Должен вернуть not_found (не "scope_denied") чтобы не leak existence.
+ UUID targetId = UUID.randomUUID();
+ DictionaryDefinition target = mockDefinition("internal_dict", DataScope.INTERNAL, targetId);
+ when(defRepo.findByName("internal_dict")).thenReturn(Optional.of(target));
+
+ JsonNode schema = OM.readTree("""
+ {"type":"object","properties":{
+ "ref":{"type":"string","x-references":"internal_dict.code"}
+ }}""");
+ JsonNode data = OM.readTree("{\"ref\":\"X\"}");
+
+ var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0).code()).isEqualTo("x_references_target_not_found");
+ }
+
+ @Test
+ void nonStringValue_returnsError() throws Exception {
+ JsonNode schema = OM.readTree("""
+ {"type":"object","properties":{
+ "ref":{"type":"integer","x-references":"d.f"}
+ }}""");
+ JsonNode data = OM.readTree("{\"ref\":42}");
+
+ var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0).code()).isEqualTo("x_references_non_string");
+ }
+
+ @Test
+ void malformedRefSpec_returnsError() throws Exception {
+ JsonNode schema = OM.readTree("""
+ {"type":"object","properties":{
+ "ref":{"type":"string","x-references":"malformed-no-dot"}
+ }}""");
+ JsonNode data = OM.readTree("{\"ref\":\"any\"}");
+
+ var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0).code()).isEqualTo("x_references_malformed");
+ }
+
+ @Test
+ void multipleReferenceFields_validatedIndependently() throws Exception {
+ UUID dictAId = UUID.randomUUID();
+ UUID dictBId = UUID.randomUUID();
+ when(defRepo.findByName("dict_a")).thenReturn(
+ Optional.of(mockDefinition("dict_a", DataScope.PUBLIC, dictAId)));
+ when(defRepo.findByName("dict_b")).thenReturn(
+ Optional.of(mockDefinition("dict_b", DataScope.PUBLIC, dictBId)));
+ // Только в dict_a есть match. dict_b — пусто.
+ when(recRepo.findActiveByJsonField(eq(dictAId), any(), eq("c"), eq("X")))
+ .thenReturn(List.of(stubRecord()));
+ when(recRepo.findActiveByJsonField(eq(dictBId), any(), any(), any()))
+ .thenReturn(List.of());
+
+ JsonNode schema = OM.readTree("""
+ {"type":"object","properties":{
+ "refA":{"type":"string","x-references":"dict_a.c"},
+ "refB":{"type":"string","x-references":"dict_b.c"}
+ }}""");
+ JsonNode data = OM.readTree("{\"refA\":\"X\",\"refB\":\"Y\"}");
+
+ var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
+ // refA OK, refB fails → 1 error
+ assertThat(errors).hasSize(1);
+ assertThat(errors.get(0).path()).isEqualTo("/refB");
+ }
+
+ @Test
+ void nullSchema_returnsEmptyErrors() {
+ var errors = validator.validate(null, null, List.of(DataScope.PUBLIC), OffsetDateTime.now());
+ assertThat(errors).isEmpty();
+ }
+
+ // === Helpers ===
+
+ /**
+ * Mockito не может mock'нуть DictionaryDefinition на JDK 25 (см. memo
+ * "JDK 25 + Mockito subclass mock-maker incompat"). Используем real
+ * entity — у неё public ctor с минимальными полями.
+ */
+ private static DictionaryDefinition mockDefinition(String name, DataScope scope, UUID id) {
+ return new DictionaryDefinition(id, name, scope, null);
+ }
+
+ /** Аналогично — real DictionaryRecord vместо mock. Validator только
+ * проверяет {@code isEmpty()}, fields этого record нам не важны. */
+ private static DictionaryRecord stubRecord() {
+ return new DictionaryRecord(
+ UUID.randomUUID(), UUID.randomUUID(), "stub-key",
+ OM.createObjectNode(), DataScope.PUBLIC, OffsetDateTime.now());
+ }
+}