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:
+144
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -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);
|
||||
}
|
||||
}
|
||||
+17
@@ -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;
|
||||
}
|
||||
}
|
||||
+14
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user