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
+12
View File
@@ -35,5 +35,17 @@
<groupId>cloud.nstart.terravault.ordinis</groupId>
<artifactId>ordinis-outbox</artifactId>
</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>
</project>
@@ -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) {}
@@ -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) {}
@@ -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());
}
}
@@ -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());
}
}
@@ -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());
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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());
}
}
@@ -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));
}
}
@@ -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);
}
}