feat(read-api): locale negotiation + scope filtering + multi-locale flatten

ordinis-read-api модуль полностью реализован:

- OrdinisReadApiApplication main + EnableJpaRepositories/EntityScan
- LocaleNegotiator: RFC 7231 / Locale.LanguageRange.parse + Locale.lookup
  по supported_locales справочника, fallback на default_locale,
  поддержка Accept-Language: * (raw mode)
- LocaleAwareJsonFlattener: walk schema_json.properties, для x-localized
  полей в data заменяет {locale:text} на string выбранной локали
  с fallback на default_locale; recursive walk для nested objects
- RecordReadService: read с scope filtering + locale negotiation +
  flatten, метрика ordinis_read_api_query_duration_seconds, scope
  access enforcement (ScopeAccessDeniedException)
- DictionaryReadController: GET /api/v1/{name}/records/{businessKey}
  и list, with @RequestHeader(Accept-Language), Content-Language
  response header, Vary: Accept-Language; query param as_scope для
  v1 (заменим на JWT scope claim позже)
- ReadApiExceptionHandler: 404 NoSuchElement, 403 ScopeAccessDenied,
  400 IllegalArgument, 500 fallback
- application.yml profile-based: dev (port-forward, ddl-auto=none —
  read-api не валидирует schema, это work writer/migrations), k8s
  (Vault role ordinis-read-api, hikari read-only=true)
- logback-spring.xml: MDC включает 'locale' для Loki query

E2E (port 8081 параллельно с writer на 8080):
- GET MOSCOW-1 без Accept-Language → name=Москва-1, Content-Language: ru-RU
- GET MOSCOW-1 с Accept-Language: en-US → name=Moscow-1, Content-Language: en-US
- GET MOSCOW-1 с Accept-Language: fr,en-US;q=0.8,ru;q=0.5 → выбрал en-US (fr нет в supported)
- GET MOSCOW-1 с Accept-Language: * → raw {ru-RU,en-US}, _meta.locale=null
- GET list → массив с flatten для каждой записи
- GET NONEXISTENT → 404 not_found
- GET с as_scope=INTERNAL для PUBLIC записи → 403 scope_access_denied
This commit is contained in:
Zimin A.N.
2026-05-03 13:10:07 +03:00
parent be77c30687
commit 51fcc95818
11 changed files with 675 additions and 0 deletions
@@ -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
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<springProperty scope="context" name="appName" source="spring.application.name" defaultValue="ordinis-read-api"/>
<springProperty scope="context" name="environment" source="ENVIRONMENT" defaultValue="dev"/>
<appender name="STDOUT_JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>trace_id</includeMdcKeyName>
<includeMdcKeyName>span_id</includeMdcKeyName>
<includeMdcKeyName>scope</includeMdcKeyName>
<includeMdcKeyName>dictionary</includeMdcKeyName>
<includeMdcKeyName>locale</includeMdcKeyName>
<includeMdcKeyName>request_id</includeMdcKeyName>
<includeMdcKeyName>user_id</includeMdcKeyName>
<customFields>{"service":"${appName}","environment":"${environment}"}</customFields>
</encoder>
</appender>
<appender name="STDOUT_HUMAN" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<springProfile name="dev">
<root level="INFO">
<appender-ref ref="STDOUT_HUMAN"/>
</root>
<logger name="cloud.nstart.terravault.ordinis" level="DEBUG"/>
</springProfile>
<springProfile name="!dev">
<root level="INFO">
<appender-ref ref="STDOUT_JSON"/>
</root>
<logger name="cloud.nstart.terravault.ordinis" level="DEBUG"/>
</springProfile>
</configuration>