feat(api): GET /dictionaries/{name}/changelog — schema versions timeline (backend #1)

Backend #1 из pending-endpoints brief. Frontend HistoryTabContent +
TimeTravelModal используют этот endpoint для отображения версий схемы
во времени с per-version property-level diff summary.

Implementation:
- AuditLogger.definitionCreated / definitionUpdated — пишет в audit_log
  с event_type DEFINITION_CREATED / DEFINITION_UPDATED. payload_before/
  after = JsonNode schema. record_id / business_key — NULL (dict-level event).
- DictionaryDefinitionService.create / updateSchema hook в audit logger
  (autowired, null-safe для tests).
- AuditLogRepository.findByDictionaryIdAndEventTypes — query by dict_id +
  event_type IN (...), ORDER BY event_time DESC, paginated.
- ChangelogService.findChangelog — собирает entries, computes top-level
  property-key diff (added/removed/changed). Первая entry (newest) =
  isCurrent + schemaVersion из DictionaryDefinition; старые entries
  возвращают version=null (semver-в-аудит backfill — separate migration).
- DictionaryChangelogController — GET /dictionaries/{name}/changelog?limit=50

Errors:
- 404 dictionary_not_found когда dict missing OR scope hidden (consistent
  с другими endpoints).

Tests +9: dict-not-found, scope-hidden, empty-audit, first-is-current,
diff-added/removed/changed/null-before/identical.
This commit is contained in:
Zimin A.N.
2026-05-11 23:04:09 +03:00
parent ff05ef9a9f
commit ebc90d76e9
6 changed files with 486 additions and 4 deletions
@@ -14,6 +14,22 @@ public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
List<AuditLog> findByDictionaryIdOrderByEventTimeDesc(UUID dictionaryId);
/**
* Audit entries для конкретного dict, отфильтрованные по event_type IN
* списке. Используется ChangelogService — берёт только DEFINITION_*
* events, skip RECORD_* шум.
*/
@Query("""
SELECT a FROM AuditLog a
WHERE a.dictionaryId = :dictionaryId
AND a.eventType IN :eventTypes
ORDER BY a.eventTime DESC
""")
List<AuditLog> findByDictionaryIdAndEventTypes(
@Param("dictionaryId") UUID dictionaryId,
@Param("eventTypes") List<String> eventTypes,
Pageable pageable);
List<AuditLog> findByRecordIdOrderByEventTimeDesc(UUID recordId);
List<AuditLog> findByUserIdOrderByEventTimeDesc(String userId);
@@ -48,6 +48,24 @@ public class AuditLogger {
write("RECORD_CREATED", "CREATE", d, r, null, r.getData());
}
/**
* Audit для создания справочника. record-связанные поля null'ятся
* (record_id / business_key не применимы к dict-level event).
* payload_after = schema_json текущей версии.
*/
public void definitionCreated(DictionaryDefinition d) {
write("DEFINITION_CREATED", "CREATE", d, null, null, d.getSchemaJson());
}
/**
* Audit для обновления schema справочника. payload_before — schema до
* update'а, payload_after — после. Используется для changelog / diff
* computation в frontend History tab.
*/
public void definitionUpdated(DictionaryDefinition d, JsonNode prevSchema) {
write("DEFINITION_UPDATED", "UPDATE", d, null, prevSchema, d.getSchemaJson());
}
public void recordUpdate(
DictionaryDefinition d, DictionaryRecord r, JsonNode payloadBefore) {
write("RECORD_UPDATED", "UPDATE", d, r, payloadBefore, r.getData());
@@ -71,8 +89,12 @@ public class AuditLogger {
entry.setAction(action);
entry.setDictionaryId(d.getId());
entry.setDictionaryName(d.getName());
entry.setRecordId(r.getId());
entry.setBusinessKey(r.getBusinessKey());
// Record-level events заполняют record_id / business_key; dict-level
// events (DEFINITION_*) проходят с r=null — оставляем NULL'ы.
if (r != null) {
entry.setRecordId(r.getId());
entry.setBusinessKey(r.getBusinessKey());
}
entry.setPayloadBefore(before);
entry.setPayloadAfter(after);
@@ -92,7 +114,7 @@ public class AuditLogger {
// алертилось. Compliance-кейс ловит на этой ошибке.
log.error(
"Failed to write audit_log entry: dictionary={} record_id={} action={}: {}",
d.getName(), r.getId(), action, ex.getMessage(), ex);
d.getName(), r == null ? null : r.getId(), action, ex.getMessage(), ex);
}
}
@@ -0,0 +1,178 @@
package cloud.nstart.terravault.ordinis.restapi.service;
import cloud.nstart.terravault.ordinis.auth.ScopeContext;
import cloud.nstart.terravault.ordinis.domain.audit.AuditLog;
import cloud.nstart.terravault.ordinis.domain.audit.AuditLogRepository;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
/**
* Builds schema changelog timeline для admin-UI History tab. Reads audit_log
* (DEFINITION_CREATED / DEFINITION_UPDATED events) и формирует entries с
* version / publishedAt / publishedBy / diff summary.
*
* <p>«Version» tracking: каждая audit_log row не хранит schema_version
* (на момент write мы знаем только текущий — он же финальный для этой row).
* Сейчас фронту нужно показать sequence; semver value берётся только для
* HEAD entry из {@link DictionaryDefinition#getSchemaVersion()}. Старые
* entries показываются с null version + dates. Backfill semver-в-аудит
* затея отдельной миграции (см. TODO inline).
*
* <p>Diff computation — best-effort key-set comparison:
* <ul>
* <li>{@code added}: ключи которые есть в payload_after.properties но не в before</li>
* <li>{@code removed}: ключи в before но не в after</li>
* <li>{@code changed}: ключи в обоих, но JsonNode#equals == false</li>
* </ul>
*
* <p>Frontend строит human-readable строки из этих списков ("+altitude /
* ~operator"). Пустой diff (после=до) — мутация прошла но без property
* изменений (description/metadata), показываем "—" в UI.
*/
@Service
public class ChangelogService {
private static final List<String> DEFINITION_EVENT_TYPES =
List.of("DEFINITION_CREATED", "DEFINITION_UPDATED");
private final AuditLogRepository auditRepo;
private final DictionaryDefinitionService definitionService;
private final ScopeContext scopeContext;
public ChangelogService(
AuditLogRepository auditRepo,
DictionaryDefinitionService definitionService,
ScopeContext scopeContext) {
this.auditRepo = auditRepo;
this.definitionService = definitionService;
this.scopeContext = scopeContext;
}
/**
* Build changelog response для dict. throws 404 если dict не существует
* либо caller не имеет к нему scope access.
*
* @param name dict name
* @param limit max entries (cap 200)
*/
@Transactional(readOnly = true)
public ChangelogResponse findChangelog(String name, int limit) {
DictionaryDefinition d = definitionService.findByName(name);
if (!scopeContext.canAccess(d.getScope())) {
throw OrdinisException.notFound(
"dictionary_not_found", "Dictionary not found: " + name);
}
int safeLimit = Math.max(1, Math.min(limit, 200));
List<AuditLog> rows = auditRepo.findByDictionaryIdAndEventTypes(
d.getId(), DEFINITION_EVENT_TYPES, PageRequest.of(0, safeLimit));
List<ChangelogEntry> entries = new ArrayList<>(rows.size());
for (int i = 0; i < rows.size(); i++) {
AuditLog row = rows.get(i);
boolean isCurrent = i == 0; // most recent — assumed current (DESC sort)
String version = isCurrent ? d.getSchemaVersion() : null;
DiffSummary diff = computeDiff(row.getPayloadBefore(), row.getPayloadAfter());
entries.add(new ChangelogEntry(
version,
row.getEventTime(),
new Publisher(row.getUserId(), row.getUserId()),
null, // headers (commit msg) not captured yet — see TODO
diff,
0, // recordsAffected — not tracked at audit level; future field
isCurrent));
}
return new ChangelogResponse(d.getName(), d.getSchemaVersion(), entries);
}
/**
* Compute property-key diff between two schema JsonNodes. Both nullable —
* NULL before = creation, NULL after = (deletion, не используется сейчас).
*
* <p>Only top-level properties are diffed — глубокая иерархия (nested
* schemas, allOf etc) сейчас treated как opaque "changed". Достаточно
* для UI ("+altitude / ~operator" summary).
*/
static DiffSummary computeDiff(JsonNode before, JsonNode after) {
Set<String> beforeKeys = topLevelPropertyKeys(before);
Set<String> afterKeys = topLevelPropertyKeys(after);
List<String> added = new ArrayList<>();
List<String> removed = new ArrayList<>();
List<String> changed = new ArrayList<>();
Set<String> all = new TreeSet<>();
all.addAll(beforeKeys);
all.addAll(afterKeys);
JsonNode beforeProps = before == null ? null : before.get("properties");
JsonNode afterProps = after == null ? null : after.get("properties");
for (String k : all) {
boolean inBefore = beforeKeys.contains(k);
boolean inAfter = afterKeys.contains(k);
if (inBefore && !inAfter) {
removed.add(k);
} else if (!inBefore && inAfter) {
added.add(k);
} else {
JsonNode b = beforeProps == null ? null : beforeProps.get(k);
JsonNode a = afterProps == null ? null : afterProps.get(k);
if (b != null && a != null && !b.equals(a)) {
changed.add(k);
}
}
}
return new DiffSummary(added, removed, changed);
}
private static Set<String> topLevelPropertyKeys(JsonNode schema) {
if (schema == null || !schema.isObject()) return Set.of();
JsonNode properties = schema.get("properties");
if (properties == null || !properties.isObject()) return Set.of();
Set<String> keys = new LinkedHashSet<>();
Iterator<Map.Entry<String, JsonNode>> it = properties.fields();
while (it.hasNext()) keys.add(it.next().getKey());
return keys;
}
// === DTOs ===
/** Per-version entry. version=null для старых entries без backfill. */
public record ChangelogEntry(
String version,
OffsetDateTime publishedAt,
Publisher publishedBy,
String headers,
DiffSummary diff,
int recordsAffected,
boolean isCurrent) {}
/** Author identity. sub — JWT subject claim (Keycloak user id). */
public record Publisher(String sub, String name) {}
/** Property-level change summary. */
public record DiffSummary(
List<String> added,
List<String> removed,
List<String> changed) {}
/** Top-level response. */
public record ChangelogResponse(
String dictionary,
String currentVersion,
List<ChangelogEntry> entries) {}
}
@@ -6,8 +6,11 @@ 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.audit.AuditLogger;
import cloud.nstart.terravault.ordinis.restapi.dto.CreateDictionaryRequest;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -19,11 +22,23 @@ public class DictionaryDefinitionService {
private final DictionaryDefinitionRepository repository;
private final OutboxRecorder outboxRecorder;
/** Optional — None в tests/legacy конструкторе (без audit). */
private final AuditLogger auditLogger;
@Autowired
public DictionaryDefinitionService(
DictionaryDefinitionRepository repository, OutboxRecorder outboxRecorder) {
DictionaryDefinitionRepository repository,
OutboxRecorder outboxRecorder,
AuditLogger auditLogger) {
this.repository = repository;
this.outboxRecorder = outboxRecorder;
this.auditLogger = auditLogger;
}
/** Test-friendly ctor без AuditLogger (existing tests без audit dep). */
public DictionaryDefinitionService(
DictionaryDefinitionRepository repository, OutboxRecorder outboxRecorder) {
this(repository, outboxRecorder, null);
}
@Transactional(readOnly = true)
@@ -99,6 +114,10 @@ public class DictionaryDefinitionService {
"definition:" + saved.getName(),
envelope);
if (auditLogger != null) {
auditLogger.definitionCreated(saved);
}
return saved;
}
@@ -155,6 +174,10 @@ public class DictionaryDefinitionService {
"definition:" + saved.getName(),
envelope);
if (auditLogger != null) {
auditLogger.definitionUpdated(saved, prevSchema);
}
return saved;
}
@@ -0,0 +1,46 @@
package cloud.nstart.terravault.ordinis.restapi.web;
import cloud.nstart.terravault.ordinis.restapi.service.ChangelogService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* {@code GET /api/v1/dictionaries/{name}/changelog} schema versions
* timeline для admin-UI History tab и TimeTravel modal.
*
* <p>Source: audit_log filtered by DEFINITION_* event types. Каждая запись
* содержит payload_before/after = JsonNode schemas; ChangelogService
* computes property-level diff summary.
*
* <p>Errors:
* <ul>
* <li>404 {@code dictionary_not_found} dict не существует или не виден
* caller'у (одинаковая ошибка чтобы не leak'ало existence).</li>
* </ul>
*/
@RestController
@RequestMapping("/api/v1/dictionaries")
public class DictionaryChangelogController {
private final ChangelogService service;
public DictionaryChangelogController(ChangelogService service) {
this.service = service;
}
/**
* @param name dict business name
* @param limit max entries (1..200, default 50). Pagination через cursor
* сейчас не нужен типичный dict 5-30 versions. Cursor
* может быть добавлен без breaking change позже.
*/
@GetMapping("/{name}/changelog")
public ChangelogService.ChangelogResponse changelog(
@PathVariable String name,
@RequestParam(defaultValue = "50") int limit) {
return service.findChangelog(name, limit);
}
}
@@ -0,0 +1,197 @@
package cloud.nstart.terravault.ordinis.restapi.service;
import cloud.nstart.terravault.ordinis.auth.ScopeContext;
import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.domain.audit.AuditLog;
import cloud.nstart.terravault.ordinis.domain.audit.AuditLogRepository;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.Pageable;
import java.util.Optional;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class ChangelogServiceTest {
private static final ObjectMapper OM = new ObjectMapper();
private AuditLogRepository auditRepo;
private DictionaryDefinitionRepository defRepo;
private DictionaryDefinitionService defService;
private TestScopeContext scopeCtx;
private ChangelogService service;
@BeforeEach
void setUp() {
auditRepo = mock(AuditLogRepository.class);
defRepo = mock(DictionaryDefinitionRepository.class);
// Real service с mocked repo Mockito не может subclass'ить
// DictionaryDefinitionService на JDK 25, поэтому строим реальный.
// OutboxRecorder=null findChangelog не вызывает outbox path.
defService = new DictionaryDefinitionService(defRepo, null);
// Test stub для ScopeContext (Mockito не subclass'ит её на JDK 25).
scopeCtx = new TestScopeContext();
service = new ChangelogService(auditRepo, defService, scopeCtx);
}
/** Test-friendly ScopeContext subclass позволяет программируемо tweak
* результат canAccess'а без Mockito (incompatible на JDK 25). */
static final class TestScopeContext extends ScopeContext {
private DataScope blockedScope;
TestScopeContext() { super(null); }
@Override public boolean canAccess(DataScope dataScope) {
return dataScope != blockedScope;
}
@Override public java.util.Set<DataScope> currentScopes() {
return java.util.Set.of(DataScope.PUBLIC, DataScope.INTERNAL, DataScope.RESTRICTED);
}
void block(DataScope s) { this.blockedScope = s; }
}
@Test
void changelog_dictNotFound_404() {
when(defRepo.findByName("missing")).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.findChangelog("missing", 50))
.isInstanceOf(OrdinisException.class);
}
@Test
void changelog_scopeHidden_404() {
var d = def("private", DataScope.RESTRICTED);
when(defRepo.findByName("private")).thenReturn(Optional.of(d));
scopeCtx.block(DataScope.RESTRICTED);
assertThatThrownBy(() -> service.findChangelog("private", 50))
.isInstanceOf(OrdinisException.class);
}
@Test
void changelog_emptyAudit_returnsEmptyEntries() {
var d = def("dict", DataScope.PUBLIC);
when(defRepo.findByName("dict")).thenReturn(Optional.of(d));
when(auditRepo.findByDictionaryIdAndEventTypes(any(), anyList(), any()))
.thenReturn(List.of());
var result = service.findChangelog("dict", 50);
assertThat(result.dictionary()).isEqualTo("dict");
assertThat(result.currentVersion()).isEqualTo("1.0.0");
assertThat(result.entries()).isEmpty();
}
@Test
void changelog_firstEntryIsCurrent() throws Exception {
var d = def("dict", DataScope.PUBLIC);
d.setSchemaVersion("1.3.2");
when(defRepo.findByName("dict")).thenReturn(Optional.of(d));
var newer = audit(d.getId(), "DEFINITION_UPDATED", "u1",
OffsetDateTime.parse("2026-05-01T10:00:00Z"),
OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"}}}"),
OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"},\"b\":{\"type\":\"integer\"}}}"));
var older = audit(d.getId(), "DEFINITION_CREATED", "u0",
OffsetDateTime.parse("2026-04-01T10:00:00Z"),
null,
OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"}}}"));
when(auditRepo.findByDictionaryIdAndEventTypes(any(), anyList(), any(Pageable.class)))
.thenReturn(List.of(newer, older));
var result = service.findChangelog("dict", 50);
assertThat(result.entries()).hasSize(2);
assertThat(result.entries().get(0).isCurrent()).isTrue();
assertThat(result.entries().get(0).version()).isEqualTo("1.3.2");
assertThat(result.entries().get(1).isCurrent()).isFalse();
assertThat(result.entries().get(1).version()).isNull();
}
// === Diff computation ===
@Test
void diff_addedKey() throws Exception {
JsonNode before = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"}}}");
JsonNode after = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"},\"b\":{\"type\":\"integer\"}}}");
var diff = ChangelogService.computeDiff(before, after);
assertThat(diff.added()).containsExactly("b");
assertThat(diff.removed()).isEmpty();
assertThat(diff.changed()).isEmpty();
}
@Test
void diff_removedKey() throws Exception {
JsonNode before = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"},\"b\":{\"type\":\"integer\"}}}");
JsonNode after = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"}}}");
var diff = ChangelogService.computeDiff(before, after);
assertThat(diff.removed()).containsExactly("b");
assertThat(diff.added()).isEmpty();
assertThat(diff.changed()).isEmpty();
}
@Test
void diff_changedKey() throws Exception {
JsonNode before = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"}}}");
JsonNode after = OM.readTree("{\"properties\":{\"a\":{\"type\":\"integer\"}}}");
var diff = ChangelogService.computeDiff(before, after);
assertThat(diff.changed()).containsExactly("a");
assertThat(diff.added()).isEmpty();
assertThat(diff.removed()).isEmpty();
}
@Test
void diff_nullBefore_treatedAsCreation() throws Exception {
JsonNode after = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"},\"b\":{\"type\":\"integer\"}}}");
var diff = ChangelogService.computeDiff(null, after);
assertThat(diff.added()).containsExactlyInAnyOrder("a", "b");
assertThat(diff.removed()).isEmpty();
assertThat(diff.changed()).isEmpty();
}
@Test
void diff_identicalSchemas_emptyDiff() throws Exception {
JsonNode schema = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"}}}");
var diff = ChangelogService.computeDiff(schema, schema);
assertThat(diff.added()).isEmpty();
assertThat(diff.removed()).isEmpty();
assertThat(diff.changed()).isEmpty();
}
// === Helpers ===
private static DictionaryDefinition def(String name, DataScope scope) {
JsonNode schema;
try {
schema = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"}}}");
} catch (Exception e) {
throw new RuntimeException(e);
}
return new DictionaryDefinition(UUID.randomUUID(), name, scope, schema);
}
private static AuditLog audit(
UUID dictId, String eventType, String userId,
OffsetDateTime at, JsonNode before, JsonNode after) {
AuditLog row = new AuditLog();
row.setDictionaryId(dictId);
row.setEventType(eventType);
row.setUserId(userId);
row.setEventTime(at);
row.setPayloadBefore(before);
row.setPayloadAfter(after);
return row;
}
}