feat(api): GET /changelog/{entryId}/diff — full before/after schemas
This commit is contained in:
@@ -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`
|
||||
|
||||
+74
@@ -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) {}
|
||||
|
||||
|
||||
+21
@@ -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.
|
||||
*
|
||||
|
||||
+65
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user