items, int page, int size, int total) {
+ return new PageResponse<>(items, total, page, size);
+ }
+}
diff --git a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/locale/LocaleAwareJsonFlattener.java b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/locale/LocaleAwareJsonFlattener.java
new file mode 100644
index 0000000..853257c
--- /dev/null
+++ b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/locale/LocaleAwareJsonFlattener.java
@@ -0,0 +1,82 @@
+package cloud.nstart.terravault.ordinis.readapi.locale;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.springframework.stereotype.Component;
+
+import java.util.Iterator;
+import java.util.Map;
+
+/**
+ * Сплющивает multi-locale JSON в плоское значение по выбранной локали.
+ *
+ * Walk проходит {@code schemaJson.properties}, для каждого property с
+ * {@code x-localized: true} в {@code data} заменяет object {@code {locale:text}}
+ * на string выбранной локали. Если выбранной нет — fallback на default.
+ *
+ *
Если {@code chosenLocale == null} — возвращает data как есть (raw multi-locale).
+ */
+@Component
+public class LocaleAwareJsonFlattener {
+
+ private final ObjectMapper objectMapper;
+
+ public LocaleAwareJsonFlattener(ObjectMapper objectMapper) {
+ this.objectMapper = objectMapper;
+ }
+
+ /**
+ * @param schema schema_json справочника
+ * @param data текущая запись (полный multi-locale)
+ * @param chosenLocale выбранная локаль, или null для raw
+ * @param defaultLocale fallback если chosenLocale нет в data
+ */
+ public JsonNode flatten(JsonNode schema, JsonNode data, String chosenLocale, String defaultLocale) {
+ if (chosenLocale == null) {
+ return data;
+ }
+ if (data == null || !data.isObject()) {
+ return data;
+ }
+ ObjectNode out = data.deepCopy();
+ walkAndFlatten(schema, out, chosenLocale, defaultLocale);
+ return out;
+ }
+
+ private void walkAndFlatten(JsonNode schema, ObjectNode data, String locale, String defaultLocale) {
+ if (schema == null || !schema.isObject()) return;
+ JsonNode properties = schema.get("properties");
+ if (properties == null || !properties.isObject()) return;
+
+ Iterator> it = properties.fields();
+ while (it.hasNext()) {
+ Map.Entry entry = it.next();
+ String fieldName = entry.getKey();
+ JsonNode fieldSchema = entry.getValue();
+
+ JsonNode xLocalized = fieldSchema.get("x-localized");
+ if (xLocalized != null && xLocalized.isBoolean() && xLocalized.asBoolean()) {
+ flattenLocalizedField(data, fieldName, locale, defaultLocale);
+ } else if (fieldSchema.has("properties") && data.has(fieldName) && data.get(fieldName).isObject()) {
+ walkAndFlatten(fieldSchema, (ObjectNode) data.get(fieldName), locale, defaultLocale);
+ }
+ }
+ }
+
+ private void flattenLocalizedField(ObjectNode data, String field, String locale, String defaultLocale) {
+ JsonNode value = data.get(field);
+ if (value == null || !value.isObject()) return;
+
+ JsonNode picked = value.get(locale);
+ if (picked == null || picked.isNull()) {
+ picked = value.get(defaultLocale);
+ }
+ if (picked == null || picked.isNull()) {
+ // ни запрошенной, ни default нет — оставляем null
+ data.set(field, objectMapper.nullNode());
+ } else {
+ data.set(field, picked);
+ }
+ }
+}
diff --git a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/locale/LocaleNegotiator.java b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/locale/LocaleNegotiator.java
new file mode 100644
index 0000000..10419c1
--- /dev/null
+++ b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/locale/LocaleNegotiator.java
@@ -0,0 +1,56 @@
+package cloud.nstart.terravault.ordinis.readapi.locale;
+
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Locale negotiation per RFC 7231: парсит Accept-Language, lookup'ает по
+ * supported_locales справочника, fallback на default_locale.
+ *
+ * Особенный случай — {@code Accept-Language: *} → возвращаем null, что
+ * означает «отдай как есть, не сплющивай».
+ */
+@Component
+public class LocaleNegotiator {
+
+ /**
+ * @return chosen locale (BCP 47 tag like "ru-RU"), либо null если клиент
+ * попросил wildcard "*" (= raw multi-locale в ответе).
+ */
+ public String negotiate(String acceptLanguage, String[] supported, String defaultLocale) {
+ if (supported == null || supported.length == 0) {
+ return defaultLocale;
+ }
+ if (acceptLanguage == null || acceptLanguage.isBlank()) {
+ return defaultLocale;
+ }
+ if ("*".equals(acceptLanguage.trim())) {
+ return null; // raw mode
+ }
+
+ try {
+ List ranges = Locale.LanguageRange.parse(acceptLanguage);
+ List supportedLocales = new ArrayList<>(supported.length);
+ for (String s : supported) supportedLocales.add(Locale.forLanguageTag(s));
+
+ Locale chosen = Locale.lookup(ranges, supportedLocales);
+ if (chosen != null) {
+ return chosen.toLanguageTag();
+ }
+ } catch (IllegalArgumentException ignore) {
+ // malformed Accept-Language → fall through to default
+ }
+ return defaultLocale;
+ }
+
+ /**
+ * Поддерживаемые локали справочника как list для удобства логирования.
+ */
+ public List supportedAsList(String[] supported) {
+ return supported == null ? List.of() : Arrays.asList(supported);
+ }
+}
diff --git a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/service/RecordReadService.java b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/service/RecordReadService.java
new file mode 100644
index 0000000..9f921ca
--- /dev/null
+++ b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/service/RecordReadService.java
@@ -0,0 +1,134 @@
+package cloud.nstart.terravault.ordinis.readapi.service;
+
+import cloud.nstart.terravault.ordinis.domain.DataScope;
+import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
+import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository;
+import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord;
+import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
+import cloud.nstart.terravault.ordinis.readapi.dto.FlattenedRecordResponse;
+import cloud.nstart.terravault.ordinis.readapi.locale.LocaleAwareJsonFlattener;
+import cloud.nstart.terravault.ordinis.readapi.locale.LocaleNegotiator;
+import com.fasterxml.jackson.databind.JsonNode;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.Timer;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.Optional;
+
+/**
+ * Read-side. Negotiate locale → load record from replica с фильтрацией по
+ * scope → flatten multi-locale → ответ.
+ *
+ * Подключение через transactional read-only — Hibernate использует replica
+ * connection (см. application.yml для read-api).
+ */
+@Service
+public class RecordReadService {
+
+ private final DictionaryDefinitionRepository definitionRepository;
+ private final DictionaryRecordRepository recordRepository;
+ private final LocaleNegotiator localeNegotiator;
+ private final LocaleAwareJsonFlattener flattener;
+ private final Timer queryTimer;
+
+ public RecordReadService(
+ DictionaryDefinitionRepository definitionRepository,
+ DictionaryRecordRepository recordRepository,
+ LocaleNegotiator localeNegotiator,
+ LocaleAwareJsonFlattener flattener,
+ MeterRegistry meterRegistry) {
+ this.definitionRepository = definitionRepository;
+ this.recordRepository = recordRepository;
+ this.localeNegotiator = localeNegotiator;
+ this.flattener = flattener;
+ this.queryTimer = Timer.builder("ordinis_read_api_query_duration_seconds")
+ .description("Время чтения записи через read-api с locale negotiation")
+ .register(meterRegistry);
+ }
+
+ public record ReadResult(FlattenedRecordResponse response, String chosenLocale) {}
+
+ @Transactional(readOnly = true)
+ public ReadResult readActive(
+ String dictionaryName,
+ String businessKey,
+ String acceptLanguage,
+ List allowedScopes,
+ OffsetDateTime asOf) {
+
+ Timer.Sample sample = Timer.start();
+ try {
+ DictionaryDefinition def = definitionRepository.findByName(dictionaryName)
+ .orElseThrow(() -> new NoSuchElementException("Dictionary not found: " + dictionaryName));
+
+ if (!allowedScopes.contains(def.getScope())) {
+ throw new ScopeAccessDeniedException(
+ "Scope " + def.getScope() + " not in allowed scopes " + allowedScopes);
+ }
+
+ OffsetDateTime when = asOf == null ? OffsetDateTime.now() : asOf;
+ Optional opt = recordRepository.findActiveAt(def.getId(), businessKey, when);
+ if (opt.isEmpty()) {
+ throw new NoSuchElementException(
+ "No active record for business_key=" + businessKey + " at " + when);
+ }
+ DictionaryRecord record = opt.get();
+
+ if (!allowedScopes.contains(record.getDataScope())) {
+ throw new ScopeAccessDeniedException(
+ "Record scope " + record.getDataScope() + " not in " + allowedScopes);
+ }
+
+ String chosen = localeNegotiator.negotiate(
+ acceptLanguage, def.getSupportedLocales(), def.getDefaultLocale());
+
+ JsonNode flat = flattener.flatten(def.getSchemaJson(), record.getData(), chosen, def.getDefaultLocale());
+
+ return new ReadResult(
+ FlattenedRecordResponse.of(record, flat, chosen, def.getDefaultLocale(), when),
+ chosen);
+ } finally {
+ sample.stop(queryTimer);
+ }
+ }
+
+ @Transactional(readOnly = true)
+ public List listActive(
+ String dictionaryName,
+ String acceptLanguage,
+ List allowedScopes,
+ OffsetDateTime asOf) {
+
+ DictionaryDefinition def = definitionRepository.findByName(dictionaryName)
+ .orElseThrow(() -> new NoSuchElementException("Dictionary not found: " + dictionaryName));
+
+ if (!allowedScopes.contains(def.getScope())) {
+ throw new ScopeAccessDeniedException(
+ "Scope " + def.getScope() + " not in allowed scopes " + allowedScopes);
+ }
+
+ OffsetDateTime when = asOf == null ? OffsetDateTime.now() : asOf;
+ List records = recordRepository.findActiveByDictionaryAndScopes(
+ def.getId(), allowedScopes, when);
+
+ String chosen = localeNegotiator.negotiate(
+ acceptLanguage, def.getSupportedLocales(), def.getDefaultLocale());
+
+ return records.stream()
+ .map(r -> FlattenedRecordResponse.of(
+ r,
+ flattener.flatten(def.getSchemaJson(), r.getData(), chosen, def.getDefaultLocale()),
+ chosen,
+ def.getDefaultLocale(),
+ when))
+ .toList();
+ }
+
+ public static class ScopeAccessDeniedException extends RuntimeException {
+ public ScopeAccessDeniedException(String message) { super(message); }
+ }
+}
diff --git a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/DictionaryReadController.java b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/DictionaryReadController.java
new file mode 100644
index 0000000..4811107
--- /dev/null
+++ b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/DictionaryReadController.java
@@ -0,0 +1,87 @@
+package cloud.nstart.terravault.ordinis.readapi.web;
+
+import cloud.nstart.terravault.ordinis.domain.DataScope;
+import cloud.nstart.terravault.ordinis.readapi.dto.FlattenedRecordResponse;
+import cloud.nstart.terravault.ordinis.readapi.service.RecordReadService;
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.MeterRegistry;
+import org.springframework.http.HttpHeaders;
+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.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * Read API для consumers (геопортал, Альтум). Фронт публичный — здесь
+ * locale negotiation + scope filtering.
+ *
+ * Scope authorisation: в v1 берём из query param {@code as_scope} (для
+ * dev/curl). В prod — из JWT (@nstart/auth) — сделаем через filter позже.
+ */
+@RestController
+@RequestMapping("/api/v1/{dictionaryName}/records")
+public class DictionaryReadController {
+
+ private final RecordReadService service;
+ private final Counter scopeDeniedCounter;
+
+ public DictionaryReadController(RecordReadService service, MeterRegistry meterRegistry) {
+ this.service = service;
+ this.scopeDeniedCounter = Counter.builder("ordinis_scope_access_denied_total")
+ .description("Попытки чтения недоступного scope (v1 — из query param)")
+ .register(meterRegistry);
+ }
+
+ @GetMapping("/{businessKey}")
+ public ResponseEntity getActive(
+ @PathVariable String dictionaryName,
+ @PathVariable String businessKey,
+ @RequestHeader(value = HttpHeaders.ACCEPT_LANGUAGE, required = false) String acceptLanguage,
+ @RequestParam(value = "as_scope", defaultValue = "PUBLIC") String asScope,
+ @RequestParam(value = "at", required = false) OffsetDateTime at) {
+
+ List scopes = parseScopes(asScope);
+ var result = service.readActive(dictionaryName, businessKey, acceptLanguage, scopes, at);
+
+ HttpHeaders headers = new HttpHeaders();
+ if (result.chosenLocale() != null) {
+ headers.add(HttpHeaders.CONTENT_LANGUAGE, result.chosenLocale());
+ }
+ headers.add(HttpHeaders.VARY, HttpHeaders.ACCEPT_LANGUAGE);
+ return new ResponseEntity<>(result.response(), headers, 200);
+ }
+
+ @GetMapping
+ public ResponseEntity> list(
+ @PathVariable String dictionaryName,
+ @RequestHeader(value = HttpHeaders.ACCEPT_LANGUAGE, required = false) String acceptLanguage,
+ @RequestParam(value = "as_scope", defaultValue = "PUBLIC") String asScope,
+ @RequestParam(value = "at", required = false) OffsetDateTime at) {
+
+ List scopes = parseScopes(asScope);
+ var items = service.listActive(dictionaryName, acceptLanguage, scopes, at);
+
+ HttpHeaders headers = new HttpHeaders();
+ if (!items.isEmpty() && items.get(0)._meta() != null && items.get(0)._meta().locale() != null) {
+ headers.add(HttpHeaders.CONTENT_LANGUAGE, items.get(0)._meta().locale());
+ }
+ headers.add(HttpHeaders.VARY, HttpHeaders.ACCEPT_LANGUAGE);
+ return new ResponseEntity<>(items, headers, 200);
+ }
+
+ private List parseScopes(String asScope) {
+ return Stream.of(asScope.split(","))
+ .map(String::trim)
+ .filter(s -> !s.isEmpty())
+ .map(s -> DataScope.valueOf(s.toUpperCase()))
+ .collect(Collectors.toList());
+ }
+}
diff --git a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/ReadApiExceptionHandler.java b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/ReadApiExceptionHandler.java
new file mode 100644
index 0000000..c811e82
--- /dev/null
+++ b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/ReadApiExceptionHandler.java
@@ -0,0 +1,63 @@
+package cloud.nstart.terravault.ordinis.readapi.web;
+
+import cloud.nstart.terravault.ordinis.readapi.service.RecordReadService.ScopeAccessDeniedException;
+import com.fasterxml.jackson.annotation.JsonInclude;
+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.time.OffsetDateTime;
+import java.util.NoSuchElementException;
+
+@RestControllerAdvice
+public class ReadApiExceptionHandler {
+
+ private static final Logger log = LoggerFactory.getLogger(ReadApiExceptionHandler.class);
+
+ private final ObjectProvider tracerProvider;
+
+ public ReadApiExceptionHandler(ObjectProvider tracerProvider) {
+ this.tracerProvider = tracerProvider;
+ }
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public record ErrorBody(int status, String code, String message, String traceId, OffsetDateTime timestamp) {}
+
+ @ExceptionHandler(NoSuchElementException.class)
+ public ResponseEntity onNotFound(NoSuchElementException e) {
+ return body(HttpStatus.NOT_FOUND, "not_found", e.getMessage());
+ }
+
+ @ExceptionHandler(ScopeAccessDeniedException.class)
+ public ResponseEntity onScope(ScopeAccessDeniedException e) {
+ return body(HttpStatus.FORBIDDEN, "scope_access_denied", e.getMessage());
+ }
+
+ @ExceptionHandler(IllegalArgumentException.class)
+ public ResponseEntity onBadRequest(IllegalArgumentException e) {
+ return body(HttpStatus.BAD_REQUEST, "bad_request", e.getMessage());
+ }
+
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity onGeneric(Exception e) {
+ log.error("Unhandled exception in read-api", e);
+ return body(HttpStatus.INTERNAL_SERVER_ERROR, "internal_error", "Internal server error");
+ }
+
+ private ResponseEntity body(HttpStatus status, String code, String message) {
+ return ResponseEntity.status(status)
+ .body(new ErrorBody(status.value(), code, message, currentTraceId(), OffsetDateTime.now()));
+ }
+
+ private String currentTraceId() {
+ Tracer tracer = tracerProvider.getIfAvailable();
+ if (tracer == null) return null;
+ var span = tracer.currentSpan();
+ return span == null ? null : span.context().traceId();
+ }
+}
diff --git a/ordinis-read-api/src/main/resources/application.yml b/ordinis-read-api/src/main/resources/application.yml
new file mode 100644
index 0000000..70ef23f
--- /dev/null
+++ b/ordinis-read-api/src/main/resources/application.yml
@@ -0,0 +1,119 @@
+spring:
+ application:
+ name: ordinis-read-api
+ profiles:
+ active: ${SPRING_PROFILES_ACTIVE:dev}
+
+server:
+ port: 8081
+ shutdown: graceful
+
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health,info,prometheus,refresh,metrics,env,mappings
+ endpoint:
+ health:
+ probes:
+ enabled: true
+ show-details: when-authorized
+ group:
+ liveness:
+ include: livenessState
+ readiness:
+ include: readinessState,db
+ prometheus:
+ metrics:
+ export:
+ enabled: true
+ metrics:
+ tags:
+ application: ${spring.application.name}
+ environment: ${ENVIRONMENT:dev}
+
+otel:
+ exporter:
+ otlp:
+ endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://tempo.monitoring:4317}
+ resource:
+ attributes:
+ service.name: ${spring.application.name}
+ service.namespace: ordinis
+ deployment.environment: ${ENVIRONMENT:dev}
+ traces:
+ sampler: parentbased_traceidratio
+ sampler.arg: ${ORDINIS_TRACING_SAMPLING:0.1}
+
+logging:
+ level:
+ root: INFO
+ cloud.nstart.terravault.ordinis: DEBUG
+
+---
+# DEV: подключение к replica через port-forward.
+# Для read-api используем `ordinis-postgres-ro` сервис (read-only replica).
+# Через port-forward в local dev можно использовать тот же ordinis-postgres-rw
+# (single-node cluster, replica = тот же primary), важна только конвенция.
+spring:
+ config:
+ activate:
+ on-profile: dev
+ cloud:
+ config:
+ enabled: false
+ vault:
+ enabled: false
+ autoconfigure:
+ exclude:
+ - org.springframework.cloud.vault.config.VaultAutoConfiguration
+ - org.springframework.cloud.vault.config.VaultObservationAutoConfiguration
+ - org.springframework.cloud.vault.config.VaultReactiveAutoConfiguration
+ datasource:
+ url: jdbc:postgresql://${ORDINIS_PG_HOST:localhost}:${ORDINIS_PG_PORT:5433}/${ORDINIS_PG_DB:ordinis}
+ username: ${ORDINIS_PG_USER:ordinis_app}
+ password: ${ORDINIS_PG_PASSWORD:}
+ driver-class-name: org.postgresql.Driver
+ hikari:
+ read-only: true
+ jpa:
+ hibernate:
+ ddl-auto: none
+ properties:
+ hibernate.dialect: org.hibernate.dialect.PostgreSQLDialect
+ open-in-view: false
+
+otel:
+ sdk:
+ disabled: true
+
+---
+# K8s: подключение к replica + Vault для credentials, Cloud Config для feature flags.
+spring:
+ config:
+ activate:
+ on-profile: k8s
+ import:
+ - "configserver:${SPRING_CLOUD_CONFIG_URI:http://config-server.ordinis-dev:8888}"
+ - "vault://"
+ cloud:
+ config:
+ enabled: true
+ vault:
+ enabled: true
+ uri: ${VAULT_URI:http://vault.config:8200}
+ authentication: KUBERNETES
+ kubernetes:
+ role: ordinis-read-api
+ service-account-token-file: /var/run/secrets/kubernetes.io/serviceaccount/token
+ kv:
+ enabled: true
+ backend: secret
+ application-name: ordinis/cuod
+ datasource:
+ hikari:
+ read-only: true
+ jpa:
+ hibernate:
+ ddl-auto: none
+ open-in-view: false
diff --git a/ordinis-read-api/src/main/resources/logback-spring.xml b/ordinis-read-api/src/main/resources/logback-spring.xml
new file mode 100644
index 0000000..3dbf800
--- /dev/null
+++ b/ordinis-read-api/src/main/resources/logback-spring.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+ trace_id
+ span_id
+ scope
+ dictionary
+ locale
+ request_id
+ user_id
+ {"service":"${appName}","environment":"${environment}"}
+
+
+
+
+
+ %d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+