feat(api): PATCH /dictionaries/{name}/schema — inline non-breaking field edit (backend #4)
Backend #4 из pending-endpoints brief. Frontend Fields tab inline edit: кликаешь field row → form показывает только allowed (non-breaking) changes → submit → patch component bumps (1.4.0 → 1.4.1) без full DictionaryEditorDialog. Body: ```json { "expectedHeadVersion": "1.4.0", "field": "altitude", "changes": { "description": {"ru":"Высота орбиты"}, "minimum": 0 } } ``` Allowed change keys: description / title / minimum / maximum / enum. Breaking keys (type/format/x-references/etc) → 422 breaking_change_requires_draft. Validation: - minimum tighter than current → 422 validation_failed (могло бы invalidate existing) - maximum tighter than current → 422 validation_failed - enum removing existing values → 422 (only additions allowed inline) - expectedHeadVersion != actual → 409 version_mismatch (optimistic lock) Implementation: - PatchSchemaRequest DTO (record). - SchemaPatchService: gates dict/scope/version, applies whitelisted changes on deep-copied JsonNode, bumps patch component via bumpPatchVersion helper (semver-safe + fallback для non-semver). - DictionaryDefinitionController новый @PatchMapping. - OrdinisException.unprocessableEntity helper (422). Audit: hooks через existing AuditLogger.definitionUpdated после save. Tests +10: 4xx scenarios, description update bumps version, enum add ok, enum remove blocked, minimum expand ok / tighten blocked, version bump helpers (semver + non-semver fallback).
This commit is contained in:
+26
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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) {}
|
||||||
+4
@@ -31,4 +31,8 @@ public class OrdinisException extends RuntimeException {
|
|||||||
public static OrdinisException forbidden(String code, String message) {
|
public static OrdinisException forbidden(String code, String message) {
|
||||||
return new OrdinisException(HttpStatus.FORBIDDEN, code, message);
|
return new OrdinisException(HttpStatus.FORBIDDEN, code, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static OrdinisException unprocessableEntity(String code, String message) {
|
||||||
|
return new OrdinisException(HttpStatus.UNPROCESSABLE_ENTITY, code, message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+237
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>Применяет узкий 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.
|
||||||
|
*
|
||||||
|
* <p>Allowed change keys:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code description} — JsonNode (localized object или plain string)</li>
|
||||||
|
* <li>{@code title} — localized object</li>
|
||||||
|
* <li>{@code minimum} / {@code maximum} — numeric (expansion only —
|
||||||
|
* narrowing rejected т.к. может invalidate existing records)</li>
|
||||||
|
* <li>{@code enum} — array. Mutation должна только ADD values, not remove
|
||||||
|
* (rejecting → 422 breaking_change_requires_draft)</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>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<String> 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:
|
||||||
|
* <ul>
|
||||||
|
* <li>404 {@code dictionary_not_found}</li>
|
||||||
|
* <li>404 {@code field_not_found} — field отсутствует в schema.properties</li>
|
||||||
|
* <li>409 {@code version_mismatch} — expectedHeadVersion stale</li>
|
||||||
|
* <li>422 {@code breaking_change_requires_draft} — patch trying breaking key</li>
|
||||||
|
* <li>422 {@code validation_failed} — numeric narrowing / enum removal</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@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<Map.Entry<String, JsonNode>> it = changesNode.fields();
|
||||||
|
while (it.hasNext()) {
|
||||||
|
Map.Entry<String, JsonNode> 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<String> oldSet = new java.util.HashSet<>();
|
||||||
|
oldValue.forEach(v -> oldSet.add(v.asText()));
|
||||||
|
java.util.Set<String> 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) {}
|
||||||
|
}
|
||||||
+28
-1
@@ -5,11 +5,14 @@ import cloud.nstart.terravault.ordinis.domain.DataScope;
|
|||||||
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
|
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.dto.CreateDictionaryRequest;
|
import cloud.nstart.terravault.ordinis.restapi.dto.CreateDictionaryRequest;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.dto.DictionaryResponse;
|
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.error.OrdinisException;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
|
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.service.SchemaPatchService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
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.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.PutMapping;
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
@@ -32,14 +35,17 @@ public class DictionaryDefinitionController {
|
|||||||
private final DictionaryDefinitionService service;
|
private final DictionaryDefinitionService service;
|
||||||
private final ScopeContext scopeContext;
|
private final ScopeContext scopeContext;
|
||||||
private final DictionaryRecordRepository recordRepository;
|
private final DictionaryRecordRepository recordRepository;
|
||||||
|
private final SchemaPatchService schemaPatchService;
|
||||||
|
|
||||||
public DictionaryDefinitionController(
|
public DictionaryDefinitionController(
|
||||||
DictionaryDefinitionService service,
|
DictionaryDefinitionService service,
|
||||||
ScopeContext scopeContext,
|
ScopeContext scopeContext,
|
||||||
DictionaryRecordRepository recordRepository) {
|
DictionaryRecordRepository recordRepository,
|
||||||
|
SchemaPatchService schemaPatchService) {
|
||||||
this.service = service;
|
this.service = service;
|
||||||
this.scopeContext = scopeContext;
|
this.scopeContext = scopeContext;
|
||||||
this.recordRepository = recordRepository;
|
this.recordRepository = recordRepository;
|
||||||
|
this.schemaPatchService = schemaPatchService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -102,4 +108,25 @@ public class DictionaryDefinitionController {
|
|||||||
}
|
}
|
||||||
return DictionaryResponse.from(service.updateSchema(name, req));
|
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).
|
||||||
|
*
|
||||||
|
* <p>Errors:
|
||||||
|
* <ul>
|
||||||
|
* <li>404 {@code dictionary_not_found}</li>
|
||||||
|
* <li>404 {@code field_not_found}</li>
|
||||||
|
* <li>409 {@code version_mismatch}</li>
|
||||||
|
* <li>422 {@code breaking_change_requires_draft} / {@code validation_failed}</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+181
@@ -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<DataScope> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user