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:
@@ -2,9 +2,13 @@ package cloud.nstart.terravault.ordinis.app;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
||||||
|
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication(scanBasePackages = "cloud.nstart.terravault.ordinis")
|
@SpringBootApplication(scanBasePackages = "cloud.nstart.terravault.ordinis")
|
||||||
|
@EnableJpaRepositories(basePackages = "cloud.nstart.terravault.ordinis")
|
||||||
|
@EntityScan(basePackages = "cloud.nstart.terravault.ordinis")
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
public class OrdinisApplication {
|
public class OrdinisApplication {
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ management:
|
|||||||
endpoints:
|
endpoints:
|
||||||
web:
|
web:
|
||||||
exposure:
|
exposure:
|
||||||
include: health,info,prometheus,refresh,metrics,env
|
include: health,info,prometheus,refresh,metrics,env,mappings
|
||||||
base-path: /actuator
|
base-path: /actuator
|
||||||
endpoint:
|
endpoint:
|
||||||
health:
|
health:
|
||||||
@@ -23,7 +23,7 @@ management:
|
|||||||
liveness:
|
liveness:
|
||||||
include: livenessState
|
include: livenessState
|
||||||
readiness:
|
readiness:
|
||||||
include: readinessState
|
include: readinessState,db
|
||||||
prometheus:
|
prometheus:
|
||||||
metrics:
|
metrics:
|
||||||
export:
|
export:
|
||||||
@@ -33,7 +33,6 @@ management:
|
|||||||
application: ${spring.application.name}
|
application: ${spring.application.name}
|
||||||
environment: ${ENVIRONMENT:dev}
|
environment: ${ENVIRONMENT:dev}
|
||||||
|
|
||||||
# Defaults для OTel (можно отключить через otel.sdk.disabled=true в dev)
|
|
||||||
otel:
|
otel:
|
||||||
exporter:
|
exporter:
|
||||||
otlp:
|
otlp:
|
||||||
@@ -53,8 +52,16 @@ logging:
|
|||||||
root: INFO
|
root: INFO
|
||||||
cloud.nstart.terravault.ordinis: DEBUG
|
cloud.nstart.terravault.ordinis: DEBUG
|
||||||
|
|
||||||
|
ordinis:
|
||||||
|
outbox:
|
||||||
|
enabled: ${ORDINIS_OUTBOX_ENABLED:true}
|
||||||
|
poll-interval-ms: ${ORDINIS_OUTBOX_POLL_INTERVAL_MS:1000}
|
||||||
|
batch-size: ${ORDINIS_OUTBOX_BATCH_SIZE:100}
|
||||||
|
|
||||||
---
|
---
|
||||||
# DEV profile — local run без Cloud Config / Vault / OTel.
|
# DEV profile: подключается к cluster PG+Kafka через kubectl port-forward.
|
||||||
|
# Перед запуском: kubectl port-forward -n ordinis-dev svc/ordinis-postgres-rw 5432:5432 &
|
||||||
|
# kubectl port-forward -n ordinis-dev svc/ordinis-kafka-kafka-bootstrap 9092:9092 &
|
||||||
spring:
|
spring:
|
||||||
config:
|
config:
|
||||||
activate:
|
activate:
|
||||||
@@ -66,17 +73,45 @@ spring:
|
|||||||
enabled: false
|
enabled: false
|
||||||
autoconfigure:
|
autoconfigure:
|
||||||
exclude:
|
exclude:
|
||||||
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
|
- org.springframework.cloud.vault.config.VaultAutoConfiguration
|
||||||
- org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration
|
- org.springframework.cloud.vault.config.VaultObservationAutoConfiguration
|
||||||
- org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration
|
- org.springframework.cloud.vault.config.VaultReactiveAutoConfiguration
|
||||||
- org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration
|
datasource:
|
||||||
|
url: jdbc:postgresql://${ORDINIS_PG_HOST:localhost}:${ORDINIS_PG_PORT:5432}/${ORDINIS_PG_DB:ordinis}
|
||||||
|
username: ${ORDINIS_PG_USER:ordinis_app}
|
||||||
|
password: ${ORDINIS_PG_PASSWORD:}
|
||||||
|
driver-class-name: org.postgresql.Driver
|
||||||
|
jpa:
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: update
|
||||||
|
properties:
|
||||||
|
hibernate.dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||||
|
hibernate.format_sql: true
|
||||||
|
hibernate.jdbc.lob.non_contextual_creation: true
|
||||||
|
open-in-view: false
|
||||||
|
kafka:
|
||||||
|
bootstrap-servers: ${ORDINIS_KAFKA_BOOTSTRAP:localhost:9092}
|
||||||
|
properties:
|
||||||
|
security.protocol: ${ORDINIS_KAFKA_SECURITY:SASL_PLAINTEXT}
|
||||||
|
sasl.mechanism: ${ORDINIS_KAFKA_SASL_MECHANISM:SCRAM-SHA-512}
|
||||||
|
sasl.jaas.config: >-
|
||||||
|
org.apache.kafka.common.security.scram.ScramLoginModule required
|
||||||
|
username="${ORDINIS_KAFKA_USER:ordinis-app}"
|
||||||
|
password="${ORDINIS_KAFKA_PASSWORD:}";
|
||||||
|
producer:
|
||||||
|
acks: all
|
||||||
|
key-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||||
|
value-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||||
|
properties:
|
||||||
|
enable.idempotence: true
|
||||||
|
compression.type: lz4
|
||||||
|
|
||||||
otel:
|
otel:
|
||||||
sdk:
|
sdk:
|
||||||
disabled: true
|
disabled: true
|
||||||
|
|
||||||
---
|
---
|
||||||
# K8s profile — реальная инфра: Spring Cloud Config + Vault Kubernetes auth.
|
# K8s profile: реальная инфра в кластере. Cloud Config + Vault Kubernetes auth.
|
||||||
spring:
|
spring:
|
||||||
config:
|
config:
|
||||||
activate:
|
activate:
|
||||||
@@ -98,3 +133,7 @@ spring:
|
|||||||
enabled: true
|
enabled: true
|
||||||
backend: secret
|
backend: secret
|
||||||
application-name: ordinis/cuod
|
application-name: ordinis/cuod
|
||||||
|
jpa:
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: validate
|
||||||
|
open-in-view: false
|
||||||
|
|||||||
+16
-10
@@ -1,27 +1,28 @@
|
|||||||
package cloud.nstart.terravault.ordinis.domain.config;
|
package cloud.nstart.terravault.ordinis.domain.config;
|
||||||
|
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.data.auditing.DateTimeProvider;
|
||||||
import org.springframework.data.domain.AuditorAware;
|
import org.springframework.data.domain.AuditorAware;
|
||||||
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
|
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
|
||||||
|
|
||||||
import javax.sql.DataSource;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Включает {@code @CreatedBy}/{@code @LastModifiedBy}. Domain не знает откуда
|
* Включает {@code @CreatedBy}/{@code @LastModifiedBy}/{@code @CreatedDate}/{@code @LastModifiedDate}.
|
||||||
* приходит user identity — модули выше (app, rest-api) подключают свой
|
|
||||||
* {@link AuditorAware} (JWT subject из @nstart/auth). Default тут возвращает
|
|
||||||
* {@code "system"} для batch/internal операций.
|
|
||||||
*
|
*
|
||||||
* <p>{@link ConditionalOnBean} на {@link DataSource} — без БД (dev smoke
|
* <p>Domain не знает откуда приходит user identity — модули выше (app,
|
||||||
* profile) auditing не активируется, JPA metamodel не строится.
|
* rest-api) подключают свой {@link AuditorAware} (JWT subject из @nstart/auth).
|
||||||
|
* Default тут возвращает {@code "system"} для batch/internal операций.
|
||||||
|
*
|
||||||
|
* <p>{@link DateTimeProvider} явно возвращает {@link OffsetDateTime} — Spring
|
||||||
|
* Data Auditing по дефолту не поддерживает {@code OffsetDateTime}, так что без
|
||||||
|
* этого bean'а получим {@code IllegalArgumentException} при сохранении.
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@ConditionalOnBean(DataSource.class)
|
@EnableJpaAuditing(auditorAwareRef = "auditorAware", dateTimeProviderRef = "auditingDateTimeProvider")
|
||||||
@EnableJpaAuditing(auditorAwareRef = "auditorAware")
|
|
||||||
public class JpaAuditingConfig {
|
public class JpaAuditingConfig {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@@ -29,4 +30,9 @@ public class JpaAuditingConfig {
|
|||||||
public AuditorAware<String> auditorAware() {
|
public AuditorAware<String> auditorAware() {
|
||||||
return () -> Optional.of("system");
|
return () -> Optional.of("system");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public DateTimeProvider auditingDateTimeProvider() {
|
||||||
|
return () -> Optional.of(OffsetDateTime.now());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,18 @@
|
|||||||
<groupId>org.springframework.kafka</groupId>
|
<groupId>org.springframework.kafka</groupId>
|
||||||
<artifactId>spring-kafka</artifactId>
|
<artifactId>spring-kafka</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.micrometer</groupId>
|
||||||
|
<artifactId>micrometer-core</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.micrometer</groupId>
|
||||||
|
<artifactId>micrometer-tracing</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>cloud.nstart.terravault.ordinis</groupId>
|
<groupId>cloud.nstart.terravault.ordinis</groupId>
|
||||||
<artifactId>ordinis-domain</artifactId>
|
<artifactId>ordinis-domain</artifactId>
|
||||||
|
|||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.outbox;
|
||||||
|
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.DataScope;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Маршрутизация события в правильный scope-топик. Pattern:
|
||||||
|
* {@code ordinis.<bundle>.events.<scope>}.
|
||||||
|
*
|
||||||
|
* <p>Bundle сейчас всегда "cuod" (anchor customer); для второго клиента —
|
||||||
|
* другой bundle, своя тройка топиков. ACL/provisioning через Strimzi (см.
|
||||||
|
* ordinis-infra/k8s/kafka/topology.yaml).
|
||||||
|
*/
|
||||||
|
public final class KafkaTopicResolver {
|
||||||
|
|
||||||
|
private KafkaTopicResolver() {}
|
||||||
|
|
||||||
|
public static String topic(String bundle, DataScope scope) {
|
||||||
|
return "ordinis." + bundle + ".events." + scope.topicSuffix();
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.outbox;
|
||||||
|
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEventRepository;
|
||||||
|
import io.micrometer.core.instrument.Gauge;
|
||||||
|
import io.micrometer.core.instrument.MeterRegistry;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gauge {@code ordinis_outbox_pending_events} — сколько unpublished. Алертится
|
||||||
|
* Prometheus'ом если растёт (poller не успевает или Kafka недоступна).
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "ordinis.outbox.enabled", havingValue = "true", matchIfMissing = false)
|
||||||
|
public class OutboxLagGauge {
|
||||||
|
|
||||||
|
private final OutboxEventRepository repository;
|
||||||
|
private final MeterRegistry meterRegistry;
|
||||||
|
|
||||||
|
public OutboxLagGauge(OutboxEventRepository repository, MeterRegistry meterRegistry) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.meterRegistry = meterRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
void register() {
|
||||||
|
Gauge.builder("ordinis_outbox_pending_events", repository,
|
||||||
|
r -> (double) r.countByPublishedAtIsNull())
|
||||||
|
.description("Количество outbox events ожидающих публикацию в Kafka")
|
||||||
|
.register(meterRegistry);
|
||||||
|
}
|
||||||
|
}
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.outbox;
|
||||||
|
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEvent;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEventRepository;
|
||||||
|
import io.micrometer.core.instrument.Counter;
|
||||||
|
import io.micrometer.core.instrument.MeterRegistry;
|
||||||
|
import io.micrometer.core.instrument.Timer;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.kafka.core.KafkaTemplate;
|
||||||
|
import org.springframework.kafka.support.SendResult;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.TimeoutException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Scheduled poller для outbox. Sweep каждые {@code ordinis.outbox.poll-interval-ms},
|
||||||
|
* batch size {@code ordinis.outbox.batch-size}. Активируется только если
|
||||||
|
* {@code ordinis.outbox.enabled=true} (отключаем в read-api / projection-writer).
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "ordinis.outbox.enabled", havingValue = "true", matchIfMissing = false)
|
||||||
|
public class OutboxPoller {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(OutboxPoller.class);
|
||||||
|
|
||||||
|
private final OutboxEventRepository repository;
|
||||||
|
private final KafkaTemplate<String, String> kafkaTemplate;
|
||||||
|
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper;
|
||||||
|
private final Counter publishedCounter;
|
||||||
|
private final Counter failedCounter;
|
||||||
|
private final Timer publishTimer;
|
||||||
|
|
||||||
|
@Value("${ordinis.outbox.batch-size:100}")
|
||||||
|
private int batchSize;
|
||||||
|
|
||||||
|
@Value("${ordinis.outbox.send-timeout-ms:10000}")
|
||||||
|
private long sendTimeoutMs;
|
||||||
|
|
||||||
|
public OutboxPoller(
|
||||||
|
OutboxEventRepository repository,
|
||||||
|
KafkaTemplate<String, String> kafkaTemplate,
|
||||||
|
com.fasterxml.jackson.databind.ObjectMapper objectMapper,
|
||||||
|
MeterRegistry meterRegistry) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.kafkaTemplate = kafkaTemplate;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.publishedCounter = Counter.builder("ordinis_outbox_published_total")
|
||||||
|
.description("Количество успешно опубликованных outbox events")
|
||||||
|
.register(meterRegistry);
|
||||||
|
this.failedCounter = Counter.builder("ordinis_outbox_publish_errors_total")
|
||||||
|
.description("Количество ошибок публикации в Kafka")
|
||||||
|
.register(meterRegistry);
|
||||||
|
this.publishTimer = Timer.builder("ordinis_kafka_publish_duration_seconds")
|
||||||
|
.description("Время публикации одного события в Kafka")
|
||||||
|
.register(meterRegistry);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelayString = "${ordinis.outbox.poll-interval-ms:1000}")
|
||||||
|
@Transactional
|
||||||
|
public void poll() {
|
||||||
|
List<OutboxEvent> batch = repository.findUnpublished(PageRequest.of(0, batchSize));
|
||||||
|
if (batch.isEmpty()) return;
|
||||||
|
|
||||||
|
log.debug("Outbox poll: {} unpublished events", batch.size());
|
||||||
|
|
||||||
|
for (OutboxEvent event : batch) {
|
||||||
|
Timer.Sample sample = Timer.start();
|
||||||
|
try {
|
||||||
|
String value = objectMapper.writeValueAsString(event.getPayload());
|
||||||
|
SendResult<String, String> result = kafkaTemplate
|
||||||
|
.send(event.getKafkaTopic(), event.getKafkaKey(), value)
|
||||||
|
.get(sendTimeoutMs, TimeUnit.MILLISECONDS);
|
||||||
|
|
||||||
|
event.markPublished();
|
||||||
|
repository.save(event);
|
||||||
|
publishedCounter.increment();
|
||||||
|
sample.stop(publishTimer);
|
||||||
|
|
||||||
|
log.debug("Published outbox event id={} topic={} partition={} offset={}",
|
||||||
|
event.getId(),
|
||||||
|
event.getKafkaTopic(),
|
||||||
|
result.getRecordMetadata().partition(),
|
||||||
|
result.getRecordMetadata().offset());
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
markFailed(event, "Interrupted: " + e.getMessage());
|
||||||
|
return;
|
||||||
|
} catch (ExecutionException | TimeoutException e) {
|
||||||
|
markFailed(event, e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||||
|
} catch (Exception e) {
|
||||||
|
markFailed(event, e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void markFailed(OutboxEvent event, String error) {
|
||||||
|
log.warn("Failed to publish outbox event id={} topic={}: {}",
|
||||||
|
event.getId(), event.getKafkaTopic(), error);
|
||||||
|
event.markFailed(error);
|
||||||
|
repository.save(event);
|
||||||
|
failedCounter.increment();
|
||||||
|
}
|
||||||
|
}
|
||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.outbox;
|
||||||
|
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.DataScope;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEvent;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEventRepository;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import io.micrometer.tracing.Tracer;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запись события в outbox в той же транзакции что и изменение справочной
|
||||||
|
* записи. Гарантирует at-least-once публикацию (poller потом отправит в Kafka).
|
||||||
|
*
|
||||||
|
* <p>Tracer optional — если в кластере включён OTel, prefix trace_id/span_id
|
||||||
|
* прокинется в outbox row для корреляции.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class OutboxRecorder {
|
||||||
|
|
||||||
|
private final OutboxEventRepository repository;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final ObjectProvider<Tracer> tracerProvider;
|
||||||
|
|
||||||
|
public OutboxRecorder(
|
||||||
|
OutboxEventRepository repository,
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
ObjectProvider<Tracer> tracerProvider) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.tracerProvider = tracerProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param eventType строковый тип (например "RecordCreated")
|
||||||
|
* @param aggregateType "DictionaryRecord" или "DictionaryDefinition"
|
||||||
|
* @param aggregateId стабильный id аггрегата (UUID или dictionary name)
|
||||||
|
* @param dictionaryName имя словаря (для рутинга в Kafka топик и для логов)
|
||||||
|
* @param scope scope записи → определяет в какой топик
|
||||||
|
* @param bundle bundle (cuod, energy, ...)
|
||||||
|
* @param kafkaKey ключ публикации (обычно {@code <dictionaryName>:<businessKey>})
|
||||||
|
* @param payload объект-payload (будет сериализован в JsonNode)
|
||||||
|
*/
|
||||||
|
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||||
|
public OutboxEvent record(
|
||||||
|
String eventType,
|
||||||
|
String aggregateType,
|
||||||
|
String aggregateId,
|
||||||
|
String dictionaryName,
|
||||||
|
DataScope scope,
|
||||||
|
String bundle,
|
||||||
|
String kafkaKey,
|
||||||
|
Object payload) {
|
||||||
|
|
||||||
|
JsonNode payloadJson = objectMapper.valueToTree(payload);
|
||||||
|
String topic = KafkaTopicResolver.topic(bundle, scope);
|
||||||
|
|
||||||
|
OutboxEvent event = new OutboxEvent(
|
||||||
|
eventType, aggregateType, aggregateId, scope, payloadJson, topic, kafkaKey);
|
||||||
|
event.setDictionaryName(dictionaryName);
|
||||||
|
|
||||||
|
Tracer tracer = tracerProvider.getIfAvailable();
|
||||||
|
if (tracer != null) {
|
||||||
|
var span = tracer.currentSpan();
|
||||||
|
if (span != null) {
|
||||||
|
event.setTraceId(span.context().traceId());
|
||||||
|
event.setSpanId(span.context().spanId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return repository.save(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,5 +35,17 @@
|
|||||||
<groupId>cloud.nstart.terravault.ordinis</groupId>
|
<groupId>cloud.nstart.terravault.ordinis</groupId>
|
||||||
<artifactId>ordinis-outbox</artifactId>
|
<artifactId>ordinis-outbox</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>cloud.nstart.terravault.ordinis</groupId>
|
||||||
|
<artifactId>ordinis-events-api</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.micrometer</groupId>
|
||||||
|
<artifactId>micrometer-tracing</artifactId>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.dto;
|
||||||
|
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.DataScope;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public record CreateDictionaryRequest(
|
||||||
|
@NotBlank @Pattern(regexp = "^[a-z][a-z0-9_]{1,127}$") String name,
|
||||||
|
String displayName,
|
||||||
|
String description,
|
||||||
|
@NotNull DataScope scope,
|
||||||
|
@NotNull JsonNode schemaJson,
|
||||||
|
String schemaVersion,
|
||||||
|
String bundle,
|
||||||
|
List<String> supportedLocales,
|
||||||
|
String defaultLocale) {}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public record CreateRecordRequest(
|
||||||
|
@NotBlank String businessKey,
|
||||||
|
@NotNull JsonNode data,
|
||||||
|
String geometryWkt,
|
||||||
|
OffsetDateTime validFrom,
|
||||||
|
OffsetDateTime validTo) {}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.dto;
|
||||||
|
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.DataScope;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public record DictionaryResponse(
|
||||||
|
UUID id,
|
||||||
|
String name,
|
||||||
|
String displayName,
|
||||||
|
String description,
|
||||||
|
DataScope scope,
|
||||||
|
JsonNode schemaJson,
|
||||||
|
String schemaVersion,
|
||||||
|
String bundle,
|
||||||
|
List<String> supportedLocales,
|
||||||
|
String defaultLocale,
|
||||||
|
OffsetDateTime createdAt,
|
||||||
|
OffsetDateTime updatedAt,
|
||||||
|
String createdBy,
|
||||||
|
String updatedBy) {
|
||||||
|
|
||||||
|
public static DictionaryResponse from(DictionaryDefinition d) {
|
||||||
|
return new DictionaryResponse(
|
||||||
|
d.getId(),
|
||||||
|
d.getName(),
|
||||||
|
d.getDisplayName(),
|
||||||
|
d.getDescription(),
|
||||||
|
d.getScope(),
|
||||||
|
d.getSchemaJson(),
|
||||||
|
d.getSchemaVersion(),
|
||||||
|
d.getBundle(),
|
||||||
|
d.getSupportedLocales() == null ? List.of() : List.of(d.getSupportedLocales()),
|
||||||
|
d.getDefaultLocale(),
|
||||||
|
d.getCreatedAt(),
|
||||||
|
d.getUpdatedAt(),
|
||||||
|
d.getCreatedBy(),
|
||||||
|
d.getUpdatedBy());
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.dto;
|
||||||
|
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.DataScope;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import org.locationtech.jts.io.WKTWriter;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public record RecordResponse(
|
||||||
|
UUID id,
|
||||||
|
UUID dictionaryId,
|
||||||
|
String businessKey,
|
||||||
|
JsonNode data,
|
||||||
|
String geometryWkt,
|
||||||
|
DataScope dataScope,
|
||||||
|
OffsetDateTime validFrom,
|
||||||
|
OffsetDateTime validTo,
|
||||||
|
OffsetDateTime createdAt,
|
||||||
|
OffsetDateTime updatedAt,
|
||||||
|
String createdBy,
|
||||||
|
String updatedBy,
|
||||||
|
Long version) {
|
||||||
|
|
||||||
|
public static RecordResponse from(DictionaryRecord r) {
|
||||||
|
String wkt = r.getGeometry() == null ? null : new WKTWriter().write(r.getGeometry());
|
||||||
|
return new RecordResponse(
|
||||||
|
r.getId(),
|
||||||
|
r.getDictionaryId(),
|
||||||
|
r.getBusinessKey(),
|
||||||
|
r.getData(),
|
||||||
|
wkt,
|
||||||
|
r.getDataScope(),
|
||||||
|
r.getValidFrom(),
|
||||||
|
r.getValidTo(),
|
||||||
|
r.getCreatedAt(),
|
||||||
|
r.getUpdatedAt(),
|
||||||
|
r.getCreatedBy(),
|
||||||
|
r.getUpdatedBy(),
|
||||||
|
r.getVersion());
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.error;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public record ApiErrorResponse(
|
||||||
|
int status,
|
||||||
|
String code,
|
||||||
|
String message,
|
||||||
|
List<FieldError> fieldErrors,
|
||||||
|
String traceId,
|
||||||
|
OffsetDateTime timestamp) {
|
||||||
|
|
||||||
|
public record FieldError(String path, String code, String message) {}
|
||||||
|
|
||||||
|
public static ApiErrorResponse of(int status, String code, String message, String traceId) {
|
||||||
|
return new ApiErrorResponse(status, code, message, null, traceId, OffsetDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ApiErrorResponse withFieldErrors(
|
||||||
|
int status, String code, String message, List<FieldError> errors, String traceId) {
|
||||||
|
return new ApiErrorResponse(status, code, message, errors, traceId, OffsetDateTime.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.error;
|
||||||
|
|
||||||
|
import cloud.nstart.terravault.ordinis.validation.ValidationException;
|
||||||
|
import io.micrometer.tracing.Tracer;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||||
|
|
||||||
|
private final ObjectProvider<Tracer> tracerProvider;
|
||||||
|
|
||||||
|
public GlobalExceptionHandler(ObjectProvider<Tracer> tracerProvider) {
|
||||||
|
this.tracerProvider = tracerProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(ValidationException.class)
|
||||||
|
public ResponseEntity<ApiErrorResponse> onValidation(ValidationException e) {
|
||||||
|
List<ApiErrorResponse.FieldError> fields = e.getErrors().stream()
|
||||||
|
.map(err -> new ApiErrorResponse.FieldError(err.path(), err.code(), err.message()))
|
||||||
|
.toList();
|
||||||
|
var body = ApiErrorResponse.withFieldErrors(
|
||||||
|
422, "validation_failed", e.getMessage(), fields, currentTraceId());
|
||||||
|
return ResponseEntity.unprocessableEntity().body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(OrdinisException.class)
|
||||||
|
public ResponseEntity<ApiErrorResponse> onOrdinis(OrdinisException e) {
|
||||||
|
var body = ApiErrorResponse.of(e.getStatus().value(), e.getCode(), e.getMessage(), currentTraceId());
|
||||||
|
return ResponseEntity.status(e.getStatus()).body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(IllegalArgumentException.class)
|
||||||
|
public ResponseEntity<ApiErrorResponse> onIllegalArgument(IllegalArgumentException e) {
|
||||||
|
var body = ApiErrorResponse.of(400, "bad_request", e.getMessage(), currentTraceId());
|
||||||
|
return ResponseEntity.badRequest().body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<ApiErrorResponse> onGeneric(Exception e) {
|
||||||
|
log.error("Unhandled exception in REST API", e);
|
||||||
|
var body = ApiErrorResponse.of(500, "internal_error", "Internal server error", currentTraceId());
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String currentTraceId() {
|
||||||
|
Tracer tracer = tracerProvider.getIfAvailable();
|
||||||
|
if (tracer == null) return null;
|
||||||
|
var span = tracer.currentSpan();
|
||||||
|
return span == null ? null : span.context().traceId();
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.error;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
||||||
|
public class OrdinisException extends RuntimeException {
|
||||||
|
|
||||||
|
private final HttpStatus status;
|
||||||
|
private final String code;
|
||||||
|
|
||||||
|
public OrdinisException(HttpStatus status, String code, String message) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpStatus getStatus() { return status; }
|
||||||
|
public String getCode() { return code; }
|
||||||
|
|
||||||
|
public static OrdinisException notFound(String code, String message) {
|
||||||
|
return new OrdinisException(HttpStatus.NOT_FOUND, code, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static OrdinisException conflict(String code, String message) {
|
||||||
|
return new OrdinisException(HttpStatus.CONFLICT, code, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static OrdinisException badRequest(String code, String message) {
|
||||||
|
return new OrdinisException(HttpStatus.BAD_REQUEST, code, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static OrdinisException forbidden(String code, String message) {
|
||||||
|
return new OrdinisException(HttpStatus.FORBIDDEN, code, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.service;
|
||||||
|
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository;
|
||||||
|
import cloud.nstart.terravault.ordinis.events.EventEnvelope;
|
||||||
|
import cloud.nstart.terravault.ordinis.events.definition.DictionaryDefinitionCreated;
|
||||||
|
import cloud.nstart.terravault.ordinis.events.definition.DictionaryDefinitionUpdated;
|
||||||
|
import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.dto.CreateDictionaryRequest;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class DictionaryDefinitionService {
|
||||||
|
|
||||||
|
private final DictionaryDefinitionRepository repository;
|
||||||
|
private final OutboxRecorder outboxRecorder;
|
||||||
|
|
||||||
|
public DictionaryDefinitionService(
|
||||||
|
DictionaryDefinitionRepository repository, OutboxRecorder outboxRecorder) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.outboxRecorder = outboxRecorder;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<DictionaryDefinition> findAll() {
|
||||||
|
return repository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public DictionaryDefinition findByName(String name) {
|
||||||
|
return repository.findByName(name)
|
||||||
|
.orElseThrow(() -> OrdinisException.notFound(
|
||||||
|
"dictionary_not_found", "Dictionary not found: " + name));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public DictionaryDefinition create(CreateDictionaryRequest req) {
|
||||||
|
if (repository.existsByName(req.name())) {
|
||||||
|
throw OrdinisException.conflict(
|
||||||
|
"dictionary_already_exists", "Dictionary already exists: " + req.name());
|
||||||
|
}
|
||||||
|
|
||||||
|
var d = new DictionaryDefinition(UUID.randomUUID(), req.name(), req.scope(), req.schemaJson());
|
||||||
|
d.setDisplayName(req.displayName());
|
||||||
|
d.setDescription(req.description());
|
||||||
|
if (req.schemaVersion() != null) d.setSchemaVersion(req.schemaVersion());
|
||||||
|
if (req.bundle() != null) d.setBundle(req.bundle());
|
||||||
|
if (req.supportedLocales() != null && !req.supportedLocales().isEmpty()) {
|
||||||
|
d.setSupportedLocales(req.supportedLocales().toArray(String[]::new));
|
||||||
|
}
|
||||||
|
if (req.defaultLocale() != null) d.setDefaultLocale(req.defaultLocale());
|
||||||
|
|
||||||
|
var saved = repository.save(d);
|
||||||
|
|
||||||
|
var event = new DictionaryDefinitionCreated(
|
||||||
|
saved.getId(),
|
||||||
|
saved.getName(),
|
||||||
|
saved.getDisplayName(),
|
||||||
|
saved.getDescription(),
|
||||||
|
toEventScope(saved.getScope()),
|
||||||
|
saved.getSchemaJson(),
|
||||||
|
saved.getSchemaVersion(),
|
||||||
|
saved.getBundle(),
|
||||||
|
saved.getSupportedLocales() == null ? List.of() : List.of(saved.getSupportedLocales()),
|
||||||
|
saved.getDefaultLocale(),
|
||||||
|
saved.getCreatedAt(),
|
||||||
|
saved.getCreatedBy());
|
||||||
|
|
||||||
|
var envelope = EventEnvelope.of(saved.getName(), toEventScope(saved.getScope()), saved.getBundle(), event);
|
||||||
|
|
||||||
|
outboxRecorder.record(
|
||||||
|
"DefinitionCreated",
|
||||||
|
"DictionaryDefinition",
|
||||||
|
saved.getId().toString(),
|
||||||
|
saved.getName(),
|
||||||
|
saved.getScope(),
|
||||||
|
saved.getBundle(),
|
||||||
|
"definition:" + saved.getName(),
|
||||||
|
envelope);
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public DictionaryDefinition updateSchema(String name, CreateDictionaryRequest req) {
|
||||||
|
var existing = findByName(name);
|
||||||
|
var prevSchema = existing.getSchemaJson();
|
||||||
|
|
||||||
|
existing.setDisplayName(req.displayName());
|
||||||
|
existing.setDescription(req.description());
|
||||||
|
existing.setSchemaJson(req.schemaJson());
|
||||||
|
if (req.schemaVersion() != null) existing.setSchemaVersion(req.schemaVersion());
|
||||||
|
if (req.supportedLocales() != null && !req.supportedLocales().isEmpty()) {
|
||||||
|
existing.setSupportedLocales(req.supportedLocales().toArray(String[]::new));
|
||||||
|
}
|
||||||
|
if (req.defaultLocale() != null) existing.setDefaultLocale(req.defaultLocale());
|
||||||
|
|
||||||
|
var saved = repository.save(existing);
|
||||||
|
|
||||||
|
var event = new DictionaryDefinitionUpdated(
|
||||||
|
saved.getId(),
|
||||||
|
saved.getName(),
|
||||||
|
saved.getDisplayName(),
|
||||||
|
saved.getDescription(),
|
||||||
|
toEventScope(saved.getScope()),
|
||||||
|
saved.getSchemaJson(),
|
||||||
|
prevSchema,
|
||||||
|
saved.getSchemaVersion(),
|
||||||
|
saved.getBundle(),
|
||||||
|
saved.getSupportedLocales() == null ? List.of() : List.of(saved.getSupportedLocales()),
|
||||||
|
saved.getDefaultLocale(),
|
||||||
|
saved.getUpdatedAt(),
|
||||||
|
saved.getUpdatedBy());
|
||||||
|
|
||||||
|
var envelope = EventEnvelope.of(saved.getName(), toEventScope(saved.getScope()), saved.getBundle(), event);
|
||||||
|
|
||||||
|
outboxRecorder.record(
|
||||||
|
"DefinitionUpdated",
|
||||||
|
"DictionaryDefinition",
|
||||||
|
saved.getId().toString(),
|
||||||
|
saved.getName(),
|
||||||
|
saved.getScope(),
|
||||||
|
saved.getBundle(),
|
||||||
|
"definition:" + saved.getName(),
|
||||||
|
envelope);
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static cloud.nstart.terravault.ordinis.events.DataScope toEventScope(
|
||||||
|
cloud.nstart.terravault.ordinis.domain.DataScope d) {
|
||||||
|
return cloud.nstart.terravault.ordinis.events.DataScope.valueOf(d.name());
|
||||||
|
}
|
||||||
|
}
|
||||||
+210
@@ -0,0 +1,210 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.service;
|
||||||
|
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
|
||||||
|
import cloud.nstart.terravault.ordinis.events.EventEnvelope;
|
||||||
|
import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordCreated;
|
||||||
|
import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordDeleted;
|
||||||
|
import cloud.nstart.terravault.ordinis.events.record.DictionaryRecordUpdated;
|
||||||
|
import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
||||||
|
import cloud.nstart.terravault.ordinis.validation.RecordValidator;
|
||||||
|
import cloud.nstart.terravault.ordinis.validation.ValidationException;
|
||||||
|
import cloud.nstart.terravault.ordinis.validation.ValidationResult;
|
||||||
|
import org.locationtech.jts.geom.Geometry;
|
||||||
|
import org.locationtech.jts.io.ParseException;
|
||||||
|
import org.locationtech.jts.io.WKTReader;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Isolation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bitemporal write-side для записей. Все изменения через SERIALIZABLE
|
||||||
|
* транзакцию (FK enforcement + EXCLUSION CONSTRAINT защищают от race).
|
||||||
|
*
|
||||||
|
* <p>Update = "close current version" + "create new version" — никогда не
|
||||||
|
* UPDATE существующей строки in-place. Это обеспечивает Envers history +
|
||||||
|
* проверяемость в любой момент времени.
|
||||||
|
*
|
||||||
|
* <p>Outbox запись — в той же транзакции что и change. Гарантирует at-least-once.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class DictionaryRecordService {
|
||||||
|
|
||||||
|
private final DictionaryRecordRepository repository;
|
||||||
|
private final DictionaryDefinitionService definitionService;
|
||||||
|
private final RecordValidator validator;
|
||||||
|
private final OutboxRecorder outbox;
|
||||||
|
|
||||||
|
private static final OffsetDateTime FAR_FUTURE = OffsetDateTime.parse("9999-12-31T23:59:59Z");
|
||||||
|
|
||||||
|
public DictionaryRecordService(
|
||||||
|
DictionaryRecordRepository repository,
|
||||||
|
DictionaryDefinitionService definitionService,
|
||||||
|
RecordValidator validator,
|
||||||
|
OutboxRecorder outbox) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.definitionService = definitionService;
|
||||||
|
this.validator = validator;
|
||||||
|
this.outbox = outbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<DictionaryRecord> findActive(String dictionaryName, String businessKey, OffsetDateTime at) {
|
||||||
|
var d = definitionService.findByName(dictionaryName);
|
||||||
|
return repository.findActiveAt(d.getId(), businessKey, at);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<DictionaryRecord> findHistory(String dictionaryName, String businessKey) {
|
||||||
|
var d = definitionService.findByName(dictionaryName);
|
||||||
|
return repository.findByDictionaryIdAndBusinessKeyOrderByValidFromDesc(d.getId(), businessKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(isolation = Isolation.SERIALIZABLE)
|
||||||
|
public DictionaryRecord create(String dictionaryName, CreateRecordRequest req) {
|
||||||
|
var d = definitionService.findByName(dictionaryName);
|
||||||
|
validate(d, req);
|
||||||
|
|
||||||
|
OffsetDateTime validFrom = req.validFrom() == null ? OffsetDateTime.now() : req.validFrom();
|
||||||
|
OffsetDateTime validTo = req.validTo() == null ? FAR_FUTURE : req.validTo();
|
||||||
|
|
||||||
|
if (repository.findActiveAt(d.getId(), req.businessKey(), validFrom).isPresent()) {
|
||||||
|
throw OrdinisException.conflict(
|
||||||
|
"record_already_active",
|
||||||
|
"Запись с business_key=" + req.businessKey() + " уже активна на " + validFrom +
|
||||||
|
". Используйте PUT (update) для новой версии.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var record = new DictionaryRecord(
|
||||||
|
UUID.randomUUID(),
|
||||||
|
d.getId(),
|
||||||
|
req.businessKey(),
|
||||||
|
req.data(),
|
||||||
|
d.getScope(),
|
||||||
|
validFrom);
|
||||||
|
record.setValidTo(validTo);
|
||||||
|
record.setGeometry(parseWkt(req.geometryWkt()));
|
||||||
|
|
||||||
|
var saved = repository.save(record);
|
||||||
|
publishCreated(d, saved);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(isolation = Isolation.SERIALIZABLE)
|
||||||
|
public DictionaryRecord update(String dictionaryName, String businessKey, CreateRecordRequest req) {
|
||||||
|
var d = definitionService.findByName(dictionaryName);
|
||||||
|
validate(d, req);
|
||||||
|
|
||||||
|
OffsetDateTime newValidFrom = req.validFrom() == null ? OffsetDateTime.now() : req.validFrom();
|
||||||
|
OffsetDateTime newValidTo = req.validTo() == null ? FAR_FUTURE : req.validTo();
|
||||||
|
|
||||||
|
var current = repository.findActiveAt(d.getId(), businessKey, newValidFrom)
|
||||||
|
.orElseThrow(() -> OrdinisException.notFound(
|
||||||
|
"record_not_active",
|
||||||
|
"Нет активной записи business_key=" + businessKey + " на " + newValidFrom));
|
||||||
|
|
||||||
|
var prevData = current.getData();
|
||||||
|
UUID previousRecordId = current.getId();
|
||||||
|
|
||||||
|
current.setValidTo(newValidFrom);
|
||||||
|
repository.save(current);
|
||||||
|
|
||||||
|
var next = new DictionaryRecord(
|
||||||
|
UUID.randomUUID(),
|
||||||
|
d.getId(),
|
||||||
|
businessKey,
|
||||||
|
req.data(),
|
||||||
|
d.getScope(),
|
||||||
|
newValidFrom);
|
||||||
|
next.setValidTo(newValidTo);
|
||||||
|
next.setGeometry(parseWkt(req.geometryWkt()));
|
||||||
|
var saved = repository.save(next);
|
||||||
|
|
||||||
|
publishUpdated(d, saved, previousRecordId, prevData);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(isolation = Isolation.SERIALIZABLE)
|
||||||
|
public void close(String dictionaryName, String businessKey, OffsetDateTime closeAt, String reason) {
|
||||||
|
var d = definitionService.findByName(dictionaryName);
|
||||||
|
OffsetDateTime when = closeAt == null ? OffsetDateTime.now() : closeAt;
|
||||||
|
|
||||||
|
var current = repository.findActiveAt(d.getId(), businessKey, when)
|
||||||
|
.orElseThrow(() -> OrdinisException.notFound(
|
||||||
|
"record_not_active",
|
||||||
|
"Нет активной записи business_key=" + businessKey + " на " + when));
|
||||||
|
|
||||||
|
current.setValidTo(when);
|
||||||
|
repository.save(current);
|
||||||
|
|
||||||
|
publishDeleted(d, current, when, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// — internals —
|
||||||
|
|
||||||
|
private void validate(DictionaryDefinition d, CreateRecordRequest req) {
|
||||||
|
ValidationResult vr = validator.validate(d, req.data());
|
||||||
|
if (!vr.valid()) throw new ValidationException(vr.errors());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Geometry parseWkt(String wkt) {
|
||||||
|
if (wkt == null || wkt.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
var reader = new WKTReader();
|
||||||
|
Geometry g = reader.read(wkt);
|
||||||
|
g.setSRID(4326);
|
||||||
|
return g;
|
||||||
|
} catch (ParseException e) {
|
||||||
|
throw OrdinisException.badRequest("invalid_wkt", "Invalid WKT: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void publishCreated(DictionaryDefinition d, DictionaryRecord r) {
|
||||||
|
String wkt = r.getGeometry() == null ? null : new org.locationtech.jts.io.WKTWriter().write(r.getGeometry());
|
||||||
|
var payload = new DictionaryRecordCreated(
|
||||||
|
r.getId(), r.getBusinessKey(), r.getData(), wkt,
|
||||||
|
r.getValidFrom(), r.getValidTo(), r.getCreatedAt(), r.getCreatedBy());
|
||||||
|
var envelope = EventEnvelope.of(d.getName(), toEventScope(d.getScope()), d.getBundle(), payload);
|
||||||
|
outbox.record(
|
||||||
|
"RecordCreated", "DictionaryRecord", r.getId().toString(),
|
||||||
|
d.getName(), d.getScope(), d.getBundle(),
|
||||||
|
d.getName() + ":" + r.getBusinessKey(), envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void publishUpdated(
|
||||||
|
DictionaryDefinition d, DictionaryRecord r,
|
||||||
|
UUID prevId, com.fasterxml.jackson.databind.JsonNode prevData) {
|
||||||
|
String wkt = r.getGeometry() == null ? null : new org.locationtech.jts.io.WKTWriter().write(r.getGeometry());
|
||||||
|
var payload = new DictionaryRecordUpdated(
|
||||||
|
r.getId(), prevId, r.getBusinessKey(), r.getData(), prevData, wkt,
|
||||||
|
r.getValidFrom(), r.getValidTo(), r.getUpdatedAt(), r.getUpdatedBy());
|
||||||
|
var envelope = EventEnvelope.of(d.getName(), toEventScope(d.getScope()), d.getBundle(), payload);
|
||||||
|
outbox.record(
|
||||||
|
"RecordUpdated", "DictionaryRecord", r.getId().toString(),
|
||||||
|
d.getName(), d.getScope(), d.getBundle(),
|
||||||
|
d.getName() + ":" + r.getBusinessKey(), envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void publishDeleted(DictionaryDefinition d, DictionaryRecord r, OffsetDateTime closedAt, String reason) {
|
||||||
|
var payload = new DictionaryRecordDeleted(
|
||||||
|
r.getId(), r.getBusinessKey(), closedAt, OffsetDateTime.now(), r.getUpdatedBy(), reason);
|
||||||
|
var envelope = EventEnvelope.of(d.getName(), toEventScope(d.getScope()), d.getBundle(), payload);
|
||||||
|
outbox.record(
|
||||||
|
"RecordDeleted", "DictionaryRecord", r.getId().toString(),
|
||||||
|
d.getName(), d.getScope(), d.getBundle(),
|
||||||
|
d.getName() + ":" + r.getBusinessKey(), envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static cloud.nstart.terravault.ordinis.events.DataScope toEventScope(
|
||||||
|
cloud.nstart.terravault.ordinis.domain.DataScope d) {
|
||||||
|
return cloud.nstart.terravault.ordinis.events.DataScope.valueOf(d.name());
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.web;
|
||||||
|
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.dto.CreateDictionaryRequest;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.dto.DictionaryResponse;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/dictionaries")
|
||||||
|
public class DictionaryDefinitionController {
|
||||||
|
|
||||||
|
private final DictionaryDefinitionService service;
|
||||||
|
|
||||||
|
public DictionaryDefinitionController(DictionaryDefinitionService service) {
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public List<DictionaryResponse> list() {
|
||||||
|
return service.findAll().stream().map(DictionaryResponse::from).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{name}")
|
||||||
|
public DictionaryResponse get(@PathVariable String name) {
|
||||||
|
return DictionaryResponse.from(service.findByName(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public DictionaryResponse create(@Valid @RequestBody CreateDictionaryRequest req) {
|
||||||
|
return DictionaryResponse.from(service.create(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{name}")
|
||||||
|
public DictionaryResponse update(
|
||||||
|
@PathVariable String name,
|
||||||
|
@Valid @RequestBody CreateDictionaryRequest req) {
|
||||||
|
return DictionaryResponse.from(service.updateSchema(name, req));
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.web;
|
||||||
|
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.dto.RecordResponse;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/dictionaries/{name}/records")
|
||||||
|
public class DictionaryRecordController {
|
||||||
|
|
||||||
|
private final DictionaryRecordService service;
|
||||||
|
|
||||||
|
public DictionaryRecordController(DictionaryRecordService service) {
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{businessKey}")
|
||||||
|
public RecordResponse getActive(
|
||||||
|
@PathVariable String name,
|
||||||
|
@PathVariable String businessKey,
|
||||||
|
@RequestParam(required = false) OffsetDateTime at) {
|
||||||
|
OffsetDateTime when = at == null ? OffsetDateTime.now() : at;
|
||||||
|
return service.findActive(name, businessKey, when)
|
||||||
|
.map(RecordResponse::from)
|
||||||
|
.orElseThrow(() -> OrdinisException.notFound(
|
||||||
|
"record_not_active",
|
||||||
|
"Нет активной записи business_key=" + businessKey + " на " + when));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{businessKey}/history")
|
||||||
|
public List<RecordResponse> getHistory(
|
||||||
|
@PathVariable String name,
|
||||||
|
@PathVariable String businessKey) {
|
||||||
|
return service.findHistory(name, businessKey).stream().map(RecordResponse::from).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public RecordResponse create(
|
||||||
|
@PathVariable String name,
|
||||||
|
@Valid @RequestBody CreateRecordRequest req) {
|
||||||
|
return RecordResponse.from(service.create(name, req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{businessKey}")
|
||||||
|
public RecordResponse update(
|
||||||
|
@PathVariable String name,
|
||||||
|
@PathVariable String businessKey,
|
||||||
|
@Valid @RequestBody CreateRecordRequest req) {
|
||||||
|
return RecordResponse.from(service.update(name, businessKey, req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{businessKey}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void close(
|
||||||
|
@PathVariable String name,
|
||||||
|
@PathVariable String businessKey,
|
||||||
|
@RequestParam(required = false) OffsetDateTime at,
|
||||||
|
@RequestParam(required = false) String reason) {
|
||||||
|
service.close(name, businessKey, at, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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