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:
@@ -49,5 +49,26 @@
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-vault-config</artifactId>
|
||||
</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>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package cloud.nstart.terravault.ordinis.readapi;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
|
||||
@SpringBootApplication(scanBasePackages = "cloud.nstart.terravault.ordinis")
|
||||
@EnableJpaRepositories(basePackages = "cloud.nstart.terravault.ordinis")
|
||||
@EntityScan(basePackages = "cloud.nstart.terravault.ordinis")
|
||||
public class OrdinisReadApiApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(OrdinisReadApiApplication.class, args);
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package cloud.nstart.terravault.ordinis.readapi.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 FlattenedRecordResponse(
|
||||
UUID id,
|
||||
String businessKey,
|
||||
JsonNode data,
|
||||
String geometryWkt,
|
||||
DataScope dataScope,
|
||||
OffsetDateTime validFrom,
|
||||
OffsetDateTime validTo,
|
||||
Meta _meta) {
|
||||
|
||||
public record Meta(String locale, String defaultLocale, OffsetDateTime asOf) {}
|
||||
|
||||
public static FlattenedRecordResponse of(
|
||||
DictionaryRecord r,
|
||||
JsonNode flattenedData,
|
||||
String chosenLocale,
|
||||
String defaultLocale,
|
||||
OffsetDateTime asOf) {
|
||||
String wkt = r.getGeometry() == null ? null : new WKTWriter().write(r.getGeometry());
|
||||
return new FlattenedRecordResponse(
|
||||
r.getId(),
|
||||
r.getBusinessKey(),
|
||||
flattenedData,
|
||||
wkt,
|
||||
r.getDataScope(),
|
||||
r.getValidFrom(),
|
||||
r.getValidTo(),
|
||||
new Meta(chosenLocale, defaultLocale, asOf));
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package cloud.nstart.terravault.ordinis.readapi.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record PageResponse<T>(
|
||||
List<T> items,
|
||||
int total,
|
||||
int page,
|
||||
int size) {
|
||||
|
||||
public static <T> PageResponse<T> of(List<T> items, int page, int size, int total) {
|
||||
return new PageResponse<>(items, total, page, size);
|
||||
}
|
||||
}
|
||||
+82
@@ -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 в плоское значение по выбранной локали.
|
||||
*
|
||||
* <p>Walk проходит {@code schemaJson.properties}, для каждого property с
|
||||
* {@code x-localized: true} в {@code data} заменяет object {@code {locale:text}}
|
||||
* на string выбранной локали. Если выбранной нет — fallback на default.
|
||||
*
|
||||
* <p>Если {@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<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();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -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.
|
||||
*
|
||||
* <p>Особенный случай — {@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<Locale.LanguageRange> ranges = Locale.LanguageRange.parse(acceptLanguage);
|
||||
List<Locale> 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<String> supportedAsList(String[] supported) {
|
||||
return supported == null ? List.of() : Arrays.asList(supported);
|
||||
}
|
||||
}
|
||||
+134
@@ -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 → ответ.
|
||||
*
|
||||
* <p>Подключение через 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<DataScope> 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<DictionaryRecord> 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<FlattenedRecordResponse> listActive(
|
||||
String dictionaryName,
|
||||
String acceptLanguage,
|
||||
List<DataScope> 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<DictionaryRecord> 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); }
|
||||
}
|
||||
}
|
||||
+87
@@ -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.
|
||||
*
|
||||
* <p>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<FlattenedRecordResponse> 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<DataScope> 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<FlattenedRecordResponse>> 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<DataScope> 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<DataScope> parseScopes(String asScope) {
|
||||
return Stream.of(asScope.split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.map(s -> DataScope.valueOf(s.toUpperCase()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
+63
@@ -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<Tracer> tracerProvider;
|
||||
|
||||
public ReadApiExceptionHandler(ObjectProvider<Tracer> 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<ErrorBody> onNotFound(NoSuchElementException e) {
|
||||
return body(HttpStatus.NOT_FOUND, "not_found", e.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(ScopeAccessDeniedException.class)
|
||||
public ResponseEntity<ErrorBody> onScope(ScopeAccessDeniedException e) {
|
||||
return body(HttpStatus.FORBIDDEN, "scope_access_denied", e.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<ErrorBody> onBadRequest(IllegalArgumentException e) {
|
||||
return body(HttpStatus.BAD_REQUEST, "bad_request", e.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ErrorBody> onGeneric(Exception e) {
|
||||
log.error("Unhandled exception in read-api", e);
|
||||
return body(HttpStatus.INTERNAL_SERVER_ERROR, "internal_error", "Internal server error");
|
||||
}
|
||||
|
||||
private ResponseEntity<ErrorBody> 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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user