feat(validation,rest-api,outbox): write-side end-to-end working

ordinis-validation:
- ValidationError, ValidationResult, ValidationException
- RecordValidator: networknt JSON Schema (Draft-7) + custom rule для
  x-localized полей. Walk schema, для каждого x-localized:true property
  проверяет: значение это object с locale keys (BCP 47 pattern xx-XX),
  все ключи в supported_locales, default_locale обязательно присутствует,
  значения non-empty strings.

ordinis-outbox:
- KafkaTopicResolver: ordinis.<bundle>.events.<scope>
- OutboxRecorder: запись события в outbox в той же транзакции (MANDATORY
  propagation), с tracer для trace_id/span_id из MDC если OTel доступен
- OutboxPoller: @Scheduled, batch_size + poll_interval из config, метрики
  ordinis_outbox_published_total / publish_errors_total / kafka_publish_duration_seconds
- OutboxLagGauge: ordinis_outbox_pending_events (gauge для алертов)
- ConditionalOnProperty ordinis.outbox.enabled — отключаем в read-api/projection-writer

ordinis-rest-api:
- DTOs: CreateDictionaryRequest, DictionaryResponse, CreateRecordRequest,
  RecordResponse (с WKT serialization geometry)
- DictionaryDefinitionService: CRUD definitions, outbox events DefinitionCreated/Updated
- DictionaryRecordService: bitemporal write-side, SERIALIZABLE isolation,
  bound check FAR_FUTURE valid_to. Update = close current + create new
  (никогда in-place). Outbox event RecordCreated/Updated/Deleted.
- DictionaryDefinitionController: GET list/by-name, POST create, PUT update
- DictionaryRecordController: GET active/history, POST create, PUT update,
  DELETE (close)
- OrdinisException + ApiErrorResponse + GlobalExceptionHandler
  (422 validation, 404 not found, 409 conflict, 500 internal)

ordinis-app:
- @EnableJpaRepositories + @EntityScan для multi-package сканирования
- application.yml profile-based: dev (PG localhost через port-forward,
  ddl-auto=update, OTel disabled), k8s (Cloud Config + Vault Kubernetes auth)
- spring.kafka producer config с SASL SCRAM-SHA-512

ordinis-domain:
- JpaAuditingConfig: добавлен DateTimeProvider возвращающий OffsetDateTime
  (Spring Data Auditing по дефолту его не поддерживает, без него падает
  IllegalArgumentException 'Cannot convert LocalDateTime to OffsetDateTime')
- @ConditionalOnBean(DataSource) убран — race с lifecycle ломал auditing

E2E test (port-forward к cluster PG+Kafka):
- POST dictionary 'ground_station' → 201, JSONB schema сохранена,
  supported_locales = {ru-RU, en-US}, default_locale = ru-RU
- POST record MOSCOW-1 multi-locale name + geometry POINT(37.618 55.751) → 201
- POST record с en-US без ru-RU → 422 с двумя ошибками:
  * networknt 'required property ru-RU not found'
  * custom 'x-localized.missing_default Default locale ru-RU is required'
- outbox_events: 2 row (DefinitionCreated + RecordCreated), topic
  ordinis.cuod.events.public, ключи правильные
- revinfo + dictionary_records_aud: 1 row (Envers tx-time history активен)
This commit is contained in:
Zimin A.N.
2026-05-03 13:04:36 +03:00
parent b778832f49
commit be77c30687
24 changed files with 1246 additions and 19 deletions
@@ -0,0 +1,144 @@
package cloud.nstart.terravault.ordinis.validation;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import com.fasterxml.jackson.databind.JsonNode;
import com.networknt.schema.JsonSchema;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SchemaValidatorsConfig;
import com.networknt.schema.SpecVersion;
import com.networknt.schema.ValidationMessage;
import org.springframework.stereotype.Component;
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.regex.Pattern;
/**
* Двухступенчатая валидация записи:
*
* <ol>
* <li>Стандартный JSON Schema (Draft-7) через networknt — типы, обязательные
* поля, форматы, etc.</li>
* <li>Custom правило для полей с extension {@code x-localized: true}: значение
* должно быть object {@code {locale: text}}, обязательно содержит
* {@code default_locale} справочника, ключи matchать pattern локали.</li>
* </ol>
*/
@Component
public class RecordValidator {
private static final Pattern LOCALE_TAG = Pattern.compile("^[a-z]{2}-[A-Z]{2}$");
private final JsonSchemaFactory schemaFactory =
JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7);
/**
* Валидирует {@code data} запись справочника против определения.
* Возвращает {@link ValidationResult}, не бросает.
*/
public ValidationResult validate(DictionaryDefinition definition, JsonNode data) {
List<ValidationError> errors = new ArrayList<>();
SchemaValidatorsConfig config = SchemaValidatorsConfig.builder().build();
JsonSchema schema = schemaFactory.getSchema(definition.getSchemaJson(), config);
Set<ValidationMessage> messages = schema.validate(data);
for (ValidationMessage m : messages) {
errors.add(new ValidationError(
m.getInstanceLocation().toString(),
m.getCode(),
m.getMessage()));
}
Set<String> supportedLocales = new HashSet<>(List.of(definition.getSupportedLocales()));
String defaultLocale = definition.getDefaultLocale();
walkLocalizedFields(definition.getSchemaJson(), data, "", supportedLocales, defaultLocale, errors);
return errors.isEmpty() ? ValidationResult.ok() : ValidationResult.failed(errors);
}
/**
* Рекурсивный обход schema. Для каждого {@code properties[name]} с
* {@code x-localized: true} — проверяет значение в data.
*/
private void walkLocalizedFields(
JsonNode schema,
JsonNode data,
String path,
Set<String> supported,
String defaultLocale,
List<ValidationError> errors) {
if (schema == null || !schema.isObject()) return;
JsonNode properties = schema.get("properties");
if (properties == null || !properties.isObject()) return;
Iterator<Map.Entry<String, JsonNode>> it = properties.fields();
while (it.hasNext()) {
Map.Entry<String, JsonNode> entry = it.next();
String fieldName = entry.getKey();
JsonNode fieldSchema = entry.getValue();
String fieldPath = path.isEmpty() ? "/" + fieldName : path + "/" + fieldName;
JsonNode dataField = data == null ? null : data.get(fieldName);
JsonNode xLocalized = fieldSchema.get("x-localized");
if (xLocalized != null && xLocalized.isBoolean() && xLocalized.asBoolean()) {
validateLocalizedValue(fieldPath, dataField, supported, defaultLocale, errors);
} else if (fieldSchema.has("properties")) {
walkLocalizedFields(fieldSchema, dataField, fieldPath, supported, defaultLocale, errors);
}
}
}
private void validateLocalizedValue(
String path,
JsonNode value,
Set<String> supported,
String defaultLocale,
List<ValidationError> errors) {
if (value == null || value.isNull()) {
errors.add(new ValidationError(path, "x-localized.missing",
"Localized field is required (must be object with locale keys)"));
return;
}
if (!value.isObject()) {
errors.add(new ValidationError(path, "x-localized.not_object",
"Localized field must be an object {locale: text}"));
return;
}
boolean hasDefault = false;
Iterator<String> keys = value.fieldNames();
while (keys.hasNext()) {
String locale = keys.next();
if (!LOCALE_TAG.matcher(locale).matches()) {
errors.add(new ValidationError(path + "/" + locale, "x-localized.invalid_tag",
"Locale tag must match pattern xx-XX (BCP 47), got: " + locale));
continue;
}
if (!supported.contains(locale)) {
errors.add(new ValidationError(path + "/" + locale, "x-localized.unsupported_locale",
"Locale " + locale + " is not in supported_locales of dictionary"));
continue;
}
JsonNode text = value.get(locale);
if (text == null || !text.isTextual() || text.asText().isBlank()) {
errors.add(new ValidationError(path + "/" + locale, "x-localized.empty_value",
"Locale " + locale + " value must be non-empty string"));
}
if (locale.equals(defaultLocale)) hasDefault = true;
}
if (!hasDefault) {
errors.add(new ValidationError(path, "x-localized.missing_default",
"Default locale '" + defaultLocale + "' is required"));
}
}
}
@@ -0,0 +1,8 @@
package cloud.nstart.terravault.ordinis.validation;
public record ValidationError(String path, String code, String message) {
public static ValidationError of(String path, String code, String message) {
return new ValidationError(path, code, message);
}
}
@@ -0,0 +1,17 @@
package cloud.nstart.terravault.ordinis.validation;
import java.util.List;
public class ValidationException extends RuntimeException {
private final List<ValidationError> errors;
public ValidationException(List<ValidationError> errors) {
super("Validation failed with " + errors.size() + " error(s)");
this.errors = List.copyOf(errors);
}
public List<ValidationError> getErrors() {
return errors;
}
}
@@ -0,0 +1,14 @@
package cloud.nstart.terravault.ordinis.validation;
import java.util.List;
public record ValidationResult(boolean valid, List<ValidationError> errors) {
public static ValidationResult ok() {
return new ValidationResult(true, List.of());
}
public static ValidationResult failed(List<ValidationError> errors) {
return new ValidationResult(false, List.copyOf(errors));
}
}