From 72268f9add3dcbbc727248470bfbab6bcefee4b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Tue, 12 May 2026 08:51:28 +0000 Subject: [PATCH] =?UTF-8?q?feat(api):=20GET=20/changelog/{entryId}/diff=20?= =?UTF-8?q?=E2=80=94=20full=20before/after=20schemas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/integration/api/pending-endpoints.md | 34 +++++++++ .../restapi/service/ChangelogService.java | 74 +++++++++++++++++++ .../web/DictionaryChangelogController.java | 21 ++++++ .../restapi/service/ChangelogServiceTest.java | 65 ++++++++++++++++ 4 files changed, 194 insertions(+) diff --git a/docs/integration/api/pending-endpoints.md b/docs/integration/api/pending-endpoints.md index d1344f0..3df17eb 100644 --- a/docs/integration/api/pending-endpoints.md +++ b/docs/integration/api/pending-endpoints.md @@ -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` diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/ChangelogService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/ChangelogService.java index b036996..28c3bf7 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/ChangelogService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/ChangelogService.java @@ -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). + * + *

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 { *

{@code kind} — frontend-friendly event type label (см. * {@link ChangelogService#mapEventTypeToKind}). Используется UI для * выбора icon/color/label. + * + *

{@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) {} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryChangelogController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryChangelogController.java index 96f355e..f1a89ce 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryChangelogController.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryChangelogController.java @@ -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. + * + *

404 {@code changelog_entry_not_found} если: + *

+ * + * @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. * diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/ChangelogServiceTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/ChangelogServiceTest.java index d6dbcd8..3c3c808 100644 --- a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/ChangelogServiceTest.java +++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/ChangelogServiceTest.java @@ -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) {