From 414267807eff2e6214c53aeb46e35023c786e4b9 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Wed, 6 May 2026 17:13:47 +0300 Subject: [PATCH] feat(refs): cross-dictionary x-references validation (v2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON Schema extension `x-references: "dict_name.field"` объявляет что значение поля должно ссылаться на active record в указанном словаре. На POST/PUT record service валидирует existence + scope visibility. Пример: { "type":"object", "properties":{ "satTypeCode":{ "type":"string", "x-references":"satellite_type.code" } } } ReferenceValidator (ordinis-rest-api/service/reference/): - Walks schema properties, finds x-references markers - Для каждого ref: lookup target dictionary + active record by JSONB field value (использует existing findActiveByJsonField repo method) - Returns ValidationError list, intergrates с RecordValidator pipeline через DictionaryRecordService.validate() - Scope hide: target dict не accessible → "not_found" error (не "scope_denied"), чтобы не leak'ало existence - Optional поля: если value missing, skip (JSON Schema validation отдельно отлавливает required) Error codes: - x_references_malformed (spec не "dict.field") - x_references_dict_not_found (target dictionary не существует) - x_references_target_not_found (referenced record нет / scope hidden) - x_references_non_string (v1 поддерживает только string values) v1 ограничения (документированы в JavaDoc): - Top-level fields only (nested objects не поддержаны) - Only string values - No cascade on delete/close (orphan FK alert — v2.5) Tests (15 в ReferenceValidatorTest): - parseRef: valid, empty, missing dot, trailing dot, nested paths - validate: no refs, missing value (optional skip), existing record OK, missing record error, missing dictionary error, scope-hidden error, non-string value error, malformed spec error, multiple fields validated independently, null schema graceful Total project tests: 176 → 191. Note: Mockito не может mock entity classes на JDK 25 (subclass mock-maker limitation). Используем real entity ctor для DictionaryDefinition + DictionaryRecord в тестах. --- .../service/DictionaryRecordService.java | 15 ++ .../service/reference/ReferenceValidator.java | 184 +++++++++++++ .../service/DictionaryRecordServiceTest.java | 2 +- .../reference/ReferenceValidatorTest.java | 253 ++++++++++++++++++ 4 files changed, 453 insertions(+), 1 deletion(-) create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/reference/ReferenceValidator.java create mode 100644 ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/reference/ReferenceValidatorTest.java 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 ограничения: + *

+ */ +@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()); + } +}