diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/PatchSchemaRequest.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/PatchSchemaRequest.java new file mode 100644 index 0000000..201008d --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/PatchSchemaRequest.java @@ -0,0 +1,26 @@ +package cloud.nstart.terravault.ordinis.restapi.dto; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** + * Inline single-field schema edit per backend #4 в pending-endpoints brief. + * + *

Structured op (вместо JSON Patch RFC 6902) — более ergonomic для UI + * Fields tab inline edit. Frontend каждый field row кликает "edit" → form + * показывает только allowed (non-breaking) changes → submits эти deltas. + * + * @param expectedHeadVersion optimistic-lock — schema_version текущий + * head. Если в БД другой → 409 version_mismatch. + * @param field property name внутри schema.properties (например + * "altitude"). + * @param changes key→value pairs изменения. Allowed keys: + * description, title, minimum, maximum, enum (только + * additions). Breaking keys (type, format, x-references) + * → 422. + */ +public record PatchSchemaRequest( + @NotBlank String expectedHeadVersion, + @NotBlank String field, + @NotNull JsonNode changes) {} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/OrdinisException.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/OrdinisException.java index 2f1afcb..1d50610 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/OrdinisException.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/OrdinisException.java @@ -31,4 +31,8 @@ public class OrdinisException extends RuntimeException { public static OrdinisException forbidden(String code, String message) { return new OrdinisException(HttpStatus.FORBIDDEN, code, message); } + + public static OrdinisException unprocessableEntity(String code, String message) { + return new OrdinisException(HttpStatus.UNPROCESSABLE_ENTITY, code, message); + } } diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/SchemaPatchService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/SchemaPatchService.java new file mode 100644 index 0000000..676fdee --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/SchemaPatchService.java @@ -0,0 +1,237 @@ +package cloud.nstart.terravault.ordinis.restapi.service; + +import cloud.nstart.terravault.ordinis.auth.ScopeContext; +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition; +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository; +import cloud.nstart.terravault.ordinis.restapi.audit.AuditLogger; +import cloud.nstart.terravault.ordinis.restapi.dto.PatchSchemaRequest; +import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Iterator; +import java.util.Map; +import java.util.Set; + +/** + * Inline non-breaking schema patch — backend #4 из pending-endpoints brief. + * + *

Применяет узкий allow-list mutations к одному property без breaking + * existing records. Bumps schema_version в patch component (1.4.0 → 1.4.1). + * Cascades через DictionaryDefinitionService.updateSchema чтобы переиспользовать + * outbox emit + audit logging. + * + *

Allowed change keys: + *

+ * + *

Breaking change keys (type, format, required, x-references, *Of) → + * 422 {@code breaking_change_requires_draft}. + */ +@Service +public class SchemaPatchService { + + private static final Logger log = LoggerFactory.getLogger(SchemaPatchService.class); + + private static final Set ALLOWED_KEYS = Set.of( + "description", "title", "minimum", "maximum", "enum"); + + private final DictionaryDefinitionRepository repository; + private final DictionaryDefinitionService definitionService; + private final ScopeContext scopeContext; + /** Optional — null в test ctor'е. */ + private final AuditLogger auditLogger; + + @Autowired + public SchemaPatchService( + DictionaryDefinitionRepository repository, + DictionaryDefinitionService definitionService, + ScopeContext scopeContext, + AuditLogger auditLogger) { + this.repository = repository; + this.definitionService = definitionService; + this.scopeContext = scopeContext; + this.auditLogger = auditLogger; + } + + public SchemaPatchService( + DictionaryDefinitionRepository repository, + DictionaryDefinitionService definitionService, + ScopeContext scopeContext) { + this(repository, definitionService, scopeContext, null); + } + + /** + * Apply patch. Throws OrdinisException на validation issues: + *

+ */ + @Transactional + public PatchSchemaResponse patchSchema(String dictName, PatchSchemaRequest req) { + DictionaryDefinition d = definitionService.findByName(dictName); + if (!scopeContext.canAccess(d.getScope())) { + throw OrdinisException.notFound( + "dictionary_not_found", "Dictionary not found: " + dictName); + } + if (!d.getSchemaVersion().equals(req.expectedHeadVersion())) { + throw OrdinisException.conflict( + "version_mismatch", + "expectedHeadVersion=" + req.expectedHeadVersion() + + " but current is " + d.getSchemaVersion()); + } + + JsonNode schema = d.getSchemaJson(); + if (!(schema instanceof ObjectNode schemaObj)) { + throw OrdinisException.badRequest( + "invalid_schema", "Schema is not a JSON object"); + } + JsonNode propertiesNode = schemaObj.get("properties"); + if (!(propertiesNode instanceof ObjectNode props)) { + throw OrdinisException.notFound( + "field_not_found", "Schema has no properties"); + } + JsonNode targetProp = props.get(req.field()); + if (!(targetProp instanceof ObjectNode targetObj)) { + throw OrdinisException.notFound( + "field_not_found", "Field not in schema.properties: " + req.field()); + } + + JsonNode changesNode = req.changes(); + if (!changesNode.isObject()) { + throw OrdinisException.badRequest( + "invalid_changes", "changes must be a JSON object"); + } + + // Validate каждый change key + apply на копию target prop. + ObjectNode patched = targetObj.deepCopy(); + Iterator> it = changesNode.fields(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + String key = entry.getKey(); + JsonNode newValue = entry.getValue(); + if (!ALLOWED_KEYS.contains(key)) { + throw OrdinisException.unprocessableEntity( + "breaking_change_requires_draft", + "Key '" + key + "' requires workflow draft, not inline patch." + + " Allowed inline keys: " + ALLOWED_KEYS); + } + JsonNode oldValue = targetObj.get(key); + validateChange(key, oldValue, newValue); + patched.set(key, newValue); + } + + // Apply patched property в новый schemaJson + bump version. + ObjectNode newProps = props.deepCopy(); + newProps.set(req.field(), patched); + ObjectNode newSchema = schemaObj.deepCopy(); + newSchema.set("properties", newProps); + + String oldVersion = d.getSchemaVersion(); + String newVersion = bumpPatchVersion(oldVersion); + + JsonNode prevSchema = d.getSchemaJson(); + d.setSchemaJson(newSchema); + d.setSchemaVersion(newVersion); + DictionaryDefinition saved = repository.save(d); + + if (auditLogger != null) { + auditLogger.definitionUpdated(saved, prevSchema); + } + log.info("Schema patched: dict={} field={} oldVersion={} newVersion={}", + dictName, req.field(), oldVersion, newVersion); + + return new PatchSchemaResponse(newVersion, saved.getSchemaJson()); + } + + /** Per-key non-breaking validation. */ + private static void validateChange(String key, JsonNode oldValue, JsonNode newValue) { + switch (key) { + case "description": + case "title": + // Free-form text/object — всегда non-breaking. + return; + case "minimum": { + if (oldValue != null && oldValue.isNumber() && newValue.isNumber()) { + double oldNum = oldValue.doubleValue(); + double newNum = newValue.doubleValue(); + if (newNum > oldNum) { + throw OrdinisException.unprocessableEntity( + "validation_failed", + "minimum tighter (" + newNum + " > " + oldNum + + ") могло бы invalidate existing records. Use draft."); + } + } + return; + } + case "maximum": { + if (oldValue != null && oldValue.isNumber() && newValue.isNumber()) { + double oldNum = oldValue.doubleValue(); + double newNum = newValue.doubleValue(); + if (newNum < oldNum) { + throw OrdinisException.unprocessableEntity( + "validation_failed", + "maximum tighter (" + newNum + " < " + oldNum + + ") могло бы invalidate existing records. Use draft."); + } + } + return; + } + case "enum": { + if (oldValue != null && oldValue.isArray() && newValue.isArray()) { + // new enum должен содержать все старые values (only-additions). + java.util.Set oldSet = new java.util.HashSet<>(); + oldValue.forEach(v -> oldSet.add(v.asText())); + java.util.Set newSet = new java.util.HashSet<>(); + newValue.forEach(v -> newSet.add(v.asText())); + if (!newSet.containsAll(oldSet)) { + oldSet.removeAll(newSet); + throw OrdinisException.unprocessableEntity( + "validation_failed", + "enum removes existing values: " + oldSet + ". Use draft."); + } + } + return; + } + default: + // Shouldn't happen — ALLOWED_KEYS check выше. + throw OrdinisException.unprocessableEntity( + "breaking_change_requires_draft", "Key not allowed: " + key); + } + } + + /** + * Bump semver patch component (1.4.0 → 1.4.1). Non-semver versions + * → append ".1" / "+1". Best-effort, не throws. + */ + static String bumpPatchVersion(String version) { + if (version == null || version.isBlank()) return "1.0.1"; + String[] parts = version.split("\\."); + if (parts.length != 3) return version + ".1"; + try { + int patch = Integer.parseInt(parts[2]); + return parts[0] + "." + parts[1] + "." + (patch + 1); + } catch (NumberFormatException e) { + return version + ".1"; + } + } + + // === DTOs === + + public record PatchSchemaResponse(String newVersion, JsonNode schemaJson) {} +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java index fab05be..12182a1 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java @@ -5,11 +5,14 @@ import cloud.nstart.terravault.ordinis.domain.DataScope; import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository; import cloud.nstart.terravault.ordinis.restapi.dto.CreateDictionaryRequest; import cloud.nstart.terravault.ordinis.restapi.dto.DictionaryResponse; +import cloud.nstart.terravault.ordinis.restapi.dto.PatchSchemaRequest; import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService; +import cloud.nstart.terravault.ordinis.restapi.service.SchemaPatchService; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; @@ -32,14 +35,17 @@ public class DictionaryDefinitionController { private final DictionaryDefinitionService service; private final ScopeContext scopeContext; private final DictionaryRecordRepository recordRepository; + private final SchemaPatchService schemaPatchService; public DictionaryDefinitionController( DictionaryDefinitionService service, ScopeContext scopeContext, - DictionaryRecordRepository recordRepository) { + DictionaryRecordRepository recordRepository, + SchemaPatchService schemaPatchService) { this.service = service; this.scopeContext = scopeContext; this.recordRepository = recordRepository; + this.schemaPatchService = schemaPatchService; } @GetMapping @@ -102,4 +108,25 @@ public class DictionaryDefinitionController { } return DictionaryResponse.from(service.updateSchema(name, req)); } + + /** + * Inline non-breaking field patch — backend #4 из pending-endpoints brief. + * Bumps patch component (1.4.0 → 1.4.1). Только allow-listed keys + * (description/title/minimum/maximum/enum). + * + *

Errors: + *

    + *
  • 404 {@code dictionary_not_found}
  • + *
  • 404 {@code field_not_found}
  • + *
  • 409 {@code version_mismatch}
  • + *
  • 422 {@code breaking_change_requires_draft} / {@code validation_failed}
  • + *
+ */ + @PatchMapping("/{name}/schema") + public SchemaPatchService.PatchSchemaResponse patchSchema( + @PathVariable String name, + @org.springframework.web.bind.annotation.RequestBody + @jakarta.validation.Valid PatchSchemaRequest req) { + return schemaPatchService.patchSchema(name, req); + } } diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/SchemaPatchServiceTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/SchemaPatchServiceTest.java new file mode 100644 index 0000000..3ca9c2b --- /dev/null +++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/SchemaPatchServiceTest.java @@ -0,0 +1,181 @@ +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.definition.DictionaryDefinition; +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository; +import cloud.nstart.terravault.ordinis.restapi.dto.PatchSchemaRequest; +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 java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class SchemaPatchServiceTest { + + private static final ObjectMapper OM = new ObjectMapper(); + + private DictionaryDefinitionRepository repo; + private DictionaryDefinitionService defService; + private TestScopeContext scopeCtx; + private SchemaPatchService service; + + @BeforeEach + void setUp() { + repo = mock(DictionaryDefinitionRepository.class); + defService = new DictionaryDefinitionService(repo, null); + scopeCtx = new TestScopeContext(); + service = new SchemaPatchService(repo, defService, scopeCtx); + when(repo.save(org.mockito.ArgumentMatchers.any(DictionaryDefinition.class))) + .thenAnswer(inv -> inv.getArgument(0)); + } + + 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 currentScopes() { + return java.util.Set.of(DataScope.PUBLIC, DataScope.INTERNAL, DataScope.RESTRICTED); + } + void block(DataScope s) { this.blockedScope = s; } + } + + @Test + void patch_versionMismatch_409() throws Exception { + var d = def("dict", "1.4.0", + "{\"properties\":{\"altitude\":{\"type\":\"integer\"}}}"); + when(repo.findByName("dict")).thenReturn(Optional.of(d)); + var req = new PatchSchemaRequest("1.0.0", "altitude", + OM.readTree("{\"description\":\"updated\"}")); + assertThatThrownBy(() -> service.patchSchema("dict", req)) + .isInstanceOfSatisfying(OrdinisException.class, + e -> assertThat(e.getCode()).isEqualTo("version_mismatch")); + } + + @Test + void patch_fieldNotFound_404() throws Exception { + var d = def("dict", "1.4.0", + "{\"properties\":{\"altitude\":{\"type\":\"integer\"}}}"); + when(repo.findByName("dict")).thenReturn(Optional.of(d)); + var req = new PatchSchemaRequest("1.4.0", "mass", + OM.readTree("{\"description\":\"x\"}")); + assertThatThrownBy(() -> service.patchSchema("dict", req)) + .isInstanceOfSatisfying(OrdinisException.class, + e -> assertThat(e.getCode()).isEqualTo("field_not_found")); + } + + @Test + void patch_breakingKey_422() throws Exception { + var d = def("dict", "1.4.0", + "{\"properties\":{\"altitude\":{\"type\":\"integer\"}}}"); + when(repo.findByName("dict")).thenReturn(Optional.of(d)); + var req = new PatchSchemaRequest("1.4.0", "altitude", + OM.readTree("{\"type\":\"string\"}")); + assertThatThrownBy(() -> service.patchSchema("dict", req)) + .isInstanceOfSatisfying(OrdinisException.class, + e -> assertThat(e.getCode()).isEqualTo("breaking_change_requires_draft")); + } + + @Test + void patch_descriptionUpdate_bumpsPatchVersion() throws Exception { + var d = def("dict", "1.4.0", + "{\"properties\":{\"altitude\":{\"type\":\"integer\"}}}"); + when(repo.findByName("dict")).thenReturn(Optional.of(d)); + var req = new PatchSchemaRequest("1.4.0", "altitude", + OM.readTree("{\"description\":{\"ru\":\"Высота орбиты\",\"en\":\"Altitude\"}}")); + + var result = service.patchSchema("dict", req); + + assertThat(result.newVersion()).isEqualTo("1.4.1"); + JsonNode altitude = result.schemaJson().get("properties").get("altitude"); + assertThat(altitude.get("description").get("ru").asText()).isEqualTo("Высота орбиты"); + } + + @Test + void patch_minimumTighter_422() throws Exception { + var d = def("dict", "1.4.0", + "{\"properties\":{\"altitude\":{\"type\":\"integer\",\"minimum\":0}}}"); + when(repo.findByName("dict")).thenReturn(Optional.of(d)); + var req = new PatchSchemaRequest("1.4.0", "altitude", + OM.readTree("{\"minimum\":100}")); + assertThatThrownBy(() -> service.patchSchema("dict", req)) + .isInstanceOfSatisfying(OrdinisException.class, + e -> assertThat(e.getCode()).isEqualTo("validation_failed")); + } + + @Test + void patch_minimumExpanded_ok() throws Exception { + var d = def("dict", "1.4.0", + "{\"properties\":{\"altitude\":{\"type\":\"integer\",\"minimum\":100}}}"); + when(repo.findByName("dict")).thenReturn(Optional.of(d)); + var req = new PatchSchemaRequest("1.4.0", "altitude", + OM.readTree("{\"minimum\":0}")); + + var result = service.patchSchema("dict", req); + + assertThat(result.newVersion()).isEqualTo("1.4.1"); + } + + @Test + void patch_enumRemove_422() throws Exception { + var d = def("dict", "1.4.0", """ + {"properties":{"status":{"type":"string","enum":["live","draft","archived"]}}}"""); + when(repo.findByName("dict")).thenReturn(Optional.of(d)); + var req = new PatchSchemaRequest("1.4.0", "status", + OM.readTree("{\"enum\":[\"live\",\"draft\"]}")); + assertThatThrownBy(() -> service.patchSchema("dict", req)) + .isInstanceOfSatisfying(OrdinisException.class, + e -> assertThat(e.getCode()).isEqualTo("validation_failed")); + } + + @Test + void patch_enumAdd_ok() throws Exception { + var d = def("dict", "1.4.0", + "{\"properties\":{\"status\":{\"type\":\"string\",\"enum\":[\"live\"]}}}"); + when(repo.findByName("dict")).thenReturn(Optional.of(d)); + var req = new PatchSchemaRequest("1.4.0", "status", + OM.readTree("{\"enum\":[\"live\",\"draft\",\"archived\"]}")); + + var result = service.patchSchema("dict", req); + + assertThat(result.newVersion()).isEqualTo("1.4.1"); + assertThat(result.schemaJson().get("properties").get("status").get("enum")) + .hasSize(3); + } + + @Test + void bumpPatchVersion_basic() { + assertThat(SchemaPatchService.bumpPatchVersion("1.4.0")).isEqualTo("1.4.1"); + assertThat(SchemaPatchService.bumpPatchVersion("0.0.99")).isEqualTo("0.0.100"); + assertThat(SchemaPatchService.bumpPatchVersion("2.0.0")).isEqualTo("2.0.1"); + } + + @Test + void bumpPatchVersion_nonSemver() { + assertThat(SchemaPatchService.bumpPatchVersion("v1")).isEqualTo("v1.1"); + assertThat(SchemaPatchService.bumpPatchVersion("")).isEqualTo("1.0.1"); + assertThat(SchemaPatchService.bumpPatchVersion(null)).isEqualTo("1.0.1"); + } + + private static DictionaryDefinition def(String name, String version, String schemaJson) { + JsonNode schema; + try { + schema = OM.readTree(schemaJson); + } catch (Exception e) { + throw new RuntimeException(e); + } + var d = new DictionaryDefinition(UUID.randomUUID(), name, DataScope.PUBLIC, schema); + d.setSchemaVersion(version); + return d; + } +}