feat(api): GET /changelog/{entryId}/diff — full before/after schemas

This commit is contained in:
Александр Зимин
2026-05-12 08:51:28 +00:00
parent aa5fcac0fc
commit 72268f9add
4 changed files with 194 additions and 0 deletions
+34
View File
@@ -100,6 +100,40 @@ Time-travel modal — список всех schema versions с change summaries.
**Complexity:** medium. Нужна таблица `dictionary_schema_history` (если
ещё нет — версия + JSON snapshot + author + timestamp).
### 1.1 Full diff payload — `GET /api/v1/dictionaries/{name}/changelog/{entryId}/diff`
✅ Shipped v2.4.0+ (MR !120).
Возвращает полные `before` / `after` schemas для конкретной changelog
entry — фронт делает side-by-side render в History tab / TimeTravel
preview. `entryId` приходит из `ChangelogEntry.id` field.
Response:
```json
{
"id": 4711,
"dictionaryName": "satellite",
"eventType": "DEFINITION_UPDATED",
"kind": "definition_update",
"occurredAt": "2026-05-12T10:00:00Z",
"publishedBy": { "sub": "u-42", "name": "zimin.an" },
"before": { "type": "object", "properties": { ... } },
"after": { "type": "object", "properties": { ... } },
"summary": {
"added": ["properties.altitude"],
"removed": [],
"changed": ["properties.operator"]
}
}
```
Errors:
- 404 `changelog_entry_not_found` — id не существует, принадлежит
другому dict'у, либо не schema-timeline event (record-level/unrelated).
Existence не утекает через ID enumeration (одинаковая ошибка для
всех 404 кейсов).
---
## 2. Records snapshots — `GET /api/v1/dictionaries/{name}/snapshots`
@@ -121,6 +121,7 @@ public class ChangelogService {
DiffSummary diff = computeDiff(row.getPayloadBefore(), row.getPayloadAfter());
String kind = mapEventTypeToKind(row.getEventType(), row.getAction());
entries.add(new ChangelogEntry(
row.getId(),
version,
row.getEventTime(),
new Publisher(row.getUserId(), row.getUserId()),
@@ -134,6 +135,56 @@ public class ChangelogService {
return new ChangelogResponse(d.getName(), d.getSchemaVersion(), entries);
}
/**
* Получить full diff (before + after schemas) для конкретной changelog
* entry. Используется History tab "Сравнить с..." кнопкой и Time-travel
* preview — фронту нужны полные schemas а не keys-only summary чтобы
* показать deep diff (nested properties, enum values, x-references).
*
* <p>Throws 404 если entry не существует, не принадлежит этому dict'у,
* либо caller не имеет scope access к dict'у.
*/
@Transactional(readOnly = true)
public ChangelogDiff findEntryDiff(String name, Long entryId) {
DictionaryDefinition d = definitionService.findByName(name);
if (!scopeContext.canAccess(d.getScope())) {
throw OrdinisException.notFound(
"dictionary_not_found", "Dictionary not found: " + name);
}
AuditLog row = auditRepo.findById(entryId).orElseThrow(() ->
OrdinisException.notFound(
"changelog_entry_not_found",
"Changelog entry " + entryId + " не найдена."));
if (row.getDictionaryId() == null || !row.getDictionaryId().equals(d.getId())) {
// Принадлежит другому dict'у — притворяемся что не существует
// чтобы не утечь existence через id enumeration.
throw OrdinisException.notFound(
"changelog_entry_not_found",
"Changelog entry " + entryId + " не найдена.");
}
if (!DEFINITION_EVENT_TYPES.contains(row.getEventType())) {
// Это record-level event'а или unrelated entry — diff endpoint
// specifically для schema timeline.
throw OrdinisException.notFound(
"changelog_entry_not_found",
"Entry " + entryId + " не является schema timeline event'ом.");
}
DiffSummary summary = computeDiff(row.getPayloadBefore(), row.getPayloadAfter());
String kind = mapEventTypeToKind(row.getEventType(), row.getAction());
return new ChangelogDiff(
row.getId(),
d.getName(),
row.getEventType(),
kind,
row.getEventTime(),
new Publisher(row.getUserId(), row.getUserId()),
row.getPayloadBefore(),
row.getPayloadAfter(),
summary);
}
/**
* Map raw event_type + action в frontend-friendly kind label. Используется
* UI чтобы рендерить разные icons / colors / labels:
@@ -224,8 +275,12 @@ public class ChangelogService {
* <p>{@code kind} — frontend-friendly event type label (см.
* {@link ChangelogService#mapEventTypeToKind}). Используется UI для
* выбора icon/color/label.
*
* <p>{@code id} — audit_log row id. Используется фронтом для deep-link
* на diff endpoint ({@code GET /changelog/{id}/diff}).
*/
public record ChangelogEntry(
Long id,
String version,
OffsetDateTime publishedAt,
Publisher publishedBy,
@@ -235,6 +290,25 @@ public class ChangelogService {
boolean isCurrent,
String kind) {}
/**
* Full diff payload для одного changelog event'а — фронт делает deep
* sidd-by-side render. {@code before} = state ДО event'а, {@code after} =
* state ПОСЛЕ. Для {@code definition_create} before=null. Для
* {@code draft_create/review/decided/withdrawn} after=proposed_schema (live
* не менялся), before=live schema на момент event'а — позволяет UI
* показать «что предлагалось».
*/
public record ChangelogDiff(
Long id,
String dictionaryName,
String eventType,
String kind,
OffsetDateTime occurredAt,
Publisher publishedBy,
JsonNode before,
JsonNode after,
DiffSummary summary) {}
/** Author identity. sub — JWT subject claim (Keycloak user id). */
public record Publisher(String sub, String name) {}
@@ -51,6 +51,27 @@ public class DictionaryChangelogController {
return changelogService.findChangelog(name, limit);
}
/**
* Full diff payload для конкретной changelog entry (для side-by-side
* render'а в History tab / TimeTravel preview). Возвращает {@code before}
* и {@code after} schema JsonNodes плюс summary diff.
*
* <p>404 {@code changelog_entry_not_found} если:
* <ul>
* <li>id не существует в audit_log</li>
* <li>entry принадлежит другому dict'у (no leak existence)</li>
* <li>entry не schema timeline event (record-level / unrelated)</li>
* </ul>
*
* @param entryId audit_log.id из {@link ChangelogService.ChangelogEntry#id()}
*/
@GetMapping("/{name}/changelog/{entryId}/diff")
public ChangelogService.ChangelogDiff changelogDiff(
@PathVariable String name,
@PathVariable Long entryId) {
return changelogService.findEntryDiff(name, entryId);
}
/**
* Snapshot points (record-change buckets) для TimeTravel slider.
*
@@ -170,6 +170,71 @@ class ChangelogServiceTest {
assertThat(diff.changed()).isEmpty();
}
// === findEntryDiff(name, entryId) ==========================================
@Test
void findEntryDiff_returnsFullBeforeAfter() throws Exception {
DictionaryDefinition d = def("dict_diff", DataScope.PUBLIC);
when(defRepo.findByName("dict_diff")).thenReturn(Optional.of(d));
JsonNode before = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"}}}");
JsonNode after = OM.readTree("{\"properties\":{\"a\":{\"type\":\"string\"},\"b\":{\"type\":\"number\"}}}");
AuditLog row = audit(d.getId(), "DEFINITION_UPDATED", "alice",
OffsetDateTime.parse("2026-05-12T10:00:00Z"), before, after);
// Stub id reflection AuditLog setId is package-private, обходим через
// мокинг findById напрямую возвращая row с whatever id audit_log хранит.
when(auditRepo.findById(42L)).thenReturn(Optional.of(row));
var diff = service.findEntryDiff("dict_diff", 42L);
assertThat(diff.dictionaryName()).isEqualTo("dict_diff");
assertThat(diff.eventType()).isEqualTo("DEFINITION_UPDATED");
assertThat(diff.kind()).isEqualTo("definition_update");
assertThat(diff.before()).isEqualTo(before);
assertThat(diff.after()).isEqualTo(after);
assertThat(diff.summary().added()).containsExactly("b");
}
@Test
void findEntryDiff_otherDict_throws404() throws Exception {
DictionaryDefinition d = def("dict_X", DataScope.PUBLIC);
when(defRepo.findByName("dict_X")).thenReturn(Optional.of(d));
UUID otherDictId = UUID.randomUUID();
AuditLog row = audit(otherDictId, "DEFINITION_UPDATED", "alice",
OffsetDateTime.now(), null, null);
when(auditRepo.findById(7L)).thenReturn(Optional.of(row));
assertThatThrownBy(() -> service.findEntryDiff("dict_X", 7L))
.isInstanceOf(OrdinisException.class)
.satisfies(e -> assertThat(((OrdinisException) e).getCode())
.isEqualTo("changelog_entry_not_found"));
}
@Test
void findEntryDiff_nonSchemaEvent_throws404() throws Exception {
DictionaryDefinition d = def("dict_R", DataScope.PUBLIC);
when(defRepo.findByName("dict_R")).thenReturn(Optional.of(d));
// Record-level event (RECORD_CREATED) diff endpoint только для schema timeline.
AuditLog row = audit(d.getId(), "RECORD_CREATED", "alice",
OffsetDateTime.now(), null, null);
when(auditRepo.findById(99L)).thenReturn(Optional.of(row));
assertThatThrownBy(() -> service.findEntryDiff("dict_R", 99L))
.isInstanceOf(OrdinisException.class)
.satisfies(e -> assertThat(((OrdinisException) e).getCode())
.isEqualTo("changelog_entry_not_found"));
}
@Test
void findEntryDiff_missingId_throws404() throws Exception {
DictionaryDefinition d = def("dict_M", DataScope.PUBLIC);
when(defRepo.findByName("dict_M")).thenReturn(Optional.of(d));
when(auditRepo.findById(404L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.findEntryDiff("dict_M", 404L))
.isInstanceOf(OrdinisException.class)
.satisfies(e -> assertThat(((OrdinisException) e).getCode())
.isEqualTo("changelog_entry_not_found"));
}
// === Helpers ===
private static DictionaryDefinition def(String name, DataScope scope) {