From 186422704a0b0250d673b3aefd8a3c259aafcad1 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Mon, 20 Jul 2026 10:54:46 +0100 Subject: [PATCH] Expand Thingifier 4xx write status handling --- .../repository/ThingStoreWriteException.java | 96 +++++++++++++++++++ .../InMemoryEntityInstanceCollection.java | 38 ++------ .../repository/sqlite/SqliteThingStore.java | 16 +--- .../repository/ThingStoreContractTest.java | 94 ++++++++++++++++++ .../InMemoryEntityInstanceCollectionTest.java | 13 ++- .../ThingInstanceCardinalityCreationTest.java | 20 ++-- .../version4/todos/TodoEntityTest.java | 2 +- .../ThingCommandResultApiMapper.java | 3 + .../ApiRoutingDefinitionDocGenerator.java | 60 +++++++++++- .../application/AmendThingHandler.java | 9 ++ .../application/ApplicationError.java | 40 +++++++- .../application/CreateThingHandler.java | 5 + .../application/DeleteThingHandler.java | 3 + .../RelationshipCommandHandler.java | 5 + .../RelationshipConnectionService.java | 5 + .../application/WriteTransactionRunner.java | 22 +++++ ...leVerbPutEntityInstanceApiNonHttpTest.java | 10 +- .../RelationshipApiNonHttpTest.java | 2 +- .../RelationshipApiSqliteRepositoryTest.java | 6 +- .../VerbPostEntityInstanceApiNonHttpTest.java | 50 ++++++++-- .../VerbPutEntityInstanceApiNonHttpTest.java | 24 ++--- .../ApiRoutingDefinitionDocGeneratorTest.java | 83 ++++++++++++++++ .../api/non_http/NestedObjectsApiTest.java | 2 +- .../ThingCommandResultApiMapperTest.java | 19 +++- .../application/ThingCommandServiceTest.java | 57 +++++++++++ 25 files changed, 597 insertions(+), 87 deletions(-) create mode 100644 ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreWriteException.java create mode 100644 thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGeneratorTest.java diff --git a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreWriteException.java b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreWriteException.java new file mode 100644 index 00000000..3ecff968 --- /dev/null +++ b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreWriteException.java @@ -0,0 +1,96 @@ +package uk.co.compendiumdev.thingifier.core.repository; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; + +public final class ThingStoreWriteException extends RuntimeException { + + public enum Reason { + MAX_INSTANCE_LIMIT_REACHED, + MAX_INSTANCE_LIMIT_WOULD_BE_EXCEEDED, + DUPLICATE_PRIMARY_KEY, + MISSING_PRIMARY_KEY, + WRONG_ENTITY_TYPE + } + + private final Reason reason; + private final String entityName; + private final Map details; + + private ThingStoreWriteException( + final Reason reason, + final String entityName, + final String message, + final Map details) { + super(message); + this.reason = reason; + this.entityName = entityName; + this.details = Collections.unmodifiableMap(new HashMap<>(details)); + } + + public static ThingStoreWriteException maxInstanceLimitReached(final EntityDefinition entity) { + return new ThingStoreWriteException( + Reason.MAX_INSTANCE_LIMIT_REACHED, + entity.getName(), + String.format( + "ERROR: Cannot add instance, maximum limit of %d reached", + entity.getMaxInstanceLimit()), + Map.of("maxInstances", String.valueOf(entity.getMaxInstanceLimit()))); + } + + public static ThingStoreWriteException maxInstanceLimitWouldBeExceeded( + final EntityDefinition entity) { + return new ThingStoreWriteException( + Reason.MAX_INSTANCE_LIMIT_WOULD_BE_EXCEEDED, + entity.getName(), + String.format( + "ERROR: Cannot add instances, would exceed maximum limit of %d", + entity.getMaxInstanceLimit()), + Map.of("maxInstances", String.valueOf(entity.getMaxInstanceLimit()))); + } + + public static ThingStoreWriteException missingPrimaryKey( + final EntityDefinition entity, final String fieldName) { + return new ThingStoreWriteException( + Reason.MISSING_PRIMARY_KEY, + entity.getName(), + String.format( + "ERROR: Cannot add instance, primary key field %s not set", fieldName), + Map.of("fieldName", fieldName)); + } + + public static ThingStoreWriteException duplicatePrimaryKey( + final EntityDefinition entity, final String primaryKeyValue) { + return new ThingStoreWriteException( + Reason.DUPLICATE_PRIMARY_KEY, + entity.getName(), + "ERROR: Cannot add instance, another instance with primary key value exists: " + + primaryKeyValue, + Map.of("primaryKeyValue", primaryKeyValue)); + } + + public static ThingStoreWriteException wrongEntityType( + final EntityDefinition expectedEntity, final EntityDefinition actualEntity) { + return new ThingStoreWriteException( + Reason.WRONG_ENTITY_TYPE, + expectedEntity.getName(), + String.format( + "ERROR: Tried to add a %s instance to the %s", + actualEntity.getName(), expectedEntity.getName()), + Map.of("actualEntityName", actualEntity.getName())); + } + + public Reason reason() { + return reason; + } + + public String entityName() { + return entityName; + } + + public Map details() { + return details; + } +} diff --git a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/repository/inmemory/InMemoryEntityInstanceCollection.java b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/repository/inmemory/InMemoryEntityInstanceCollection.java index 34d1e564..6a3db164 100644 --- a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/repository/inmemory/InMemoryEntityInstanceCollection.java +++ b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/repository/inmemory/InMemoryEntityInstanceCollection.java @@ -10,6 +10,7 @@ import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; import uk.co.compendiumdev.thingifier.core.reporting.ValidationReport; import uk.co.compendiumdev.thingifier.core.repository.MutableEntityInstance; +import uk.co.compendiumdev.thingifier.core.repository.ThingStoreWriteException; final class InMemoryEntityInstanceCollection { @@ -55,10 +56,7 @@ InMemoryEntityInstanceCollection addInstances(List addInstances) if (definition.hasMaxInstanceLimit() && ((instances.size() + addInstances.size()) > definition.getMaxInstanceLimit())) { - throw new RuntimeException( - String.format( - "ERROR: Cannot add instances, would exceed maximum limit of %d", - definition.getMaxInstanceLimit())); + throw ThingStoreWriteException.maxInstanceLimitWouldBeExceeded(definition); } for (EntityInstance instance : addInstances) { @@ -77,18 +75,12 @@ EntityInstance prepareInstanceForInsert(final MutableEntityInstance mutableInsta ensureCountersInitialized(); if (mutableInstance.getEntity() != definition) { - throw new RuntimeException( - String.format( - "ERROR: Tried to add a %s instance to the %s", - mutableInstance.getEntity().getName(), definition.getName())); + throw ThingStoreWriteException.wrongEntityType(definition, mutableInstance.getEntity()); } if (definition.hasMaxInstanceLimit() && instances.size() >= definition.getMaxInstanceLimit()) { - throw new RuntimeException( - String.format( - "ERROR: Cannot add instance, maximum limit of %d reached", - definition.getMaxInstanceLimit())); + throw ThingStoreWriteException.maxInstanceLimitReached(definition); } // if there are any AUTO_GUIDs or AUTO-INCREMENTs not set in the instance, then set them now @@ -114,10 +106,8 @@ EntityInstance prepareInstanceForInsert(final MutableEntityInstance mutableInsta // check value of primary key exists and is unique Field primaryField = definition.getPrimaryKeyField(); if (!mutableInstance.hasInstantiatedFieldNamed(primaryField.getName())) { - throw new RuntimeException( - String.format( - "ERROR: Cannot add instance, primary key field %s not set", - primaryField.getName())); + throw ThingStoreWriteException.missingPrimaryKey( + definition, primaryField.getName()); } } @@ -127,10 +117,8 @@ EntityInstance prepareInstanceForInsert(final MutableEntityInstance mutableInsta for (EntityInstance existingInstance : instances.values()) { if (existingInstance.getPrimaryKeyValue().equals(instance.getPrimaryKeyValue())) { - throw new RuntimeException( - String.format( - "ERROR: Cannot add instance, another instance with primary key value exists: %s", - existingInstance.getPrimaryKeyValue())); + throw ThingStoreWriteException.duplicatePrimaryKey( + definition, existingInstance.getPrimaryKeyValue()); } } } @@ -143,18 +131,12 @@ EntityInstance addInstance(EntityInstance instance) { ensureCountersInitialized(); if (instance.getEntity() != definition) { - throw new RuntimeException( - String.format( - "ERROR: Tried to add a %s instance to the %s", - instance.getEntity().getName(), definition.getName())); + throw ThingStoreWriteException.wrongEntityType(definition, instance.getEntity()); } if (definition.hasMaxInstanceLimit() && instances.size() >= definition.getMaxInstanceLimit()) { - throw new RuntimeException( - String.format( - "ERROR: Cannot add instance, maximum limit of %d reached", - definition.getMaxInstanceLimit())); + throw ThingStoreWriteException.maxInstanceLimitReached(definition); } instances.put(instance.getInternalId(), instance); diff --git a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/repository/sqlite/SqliteThingStore.java b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/repository/sqlite/SqliteThingStore.java index 691d308b..9b1171bc 100644 --- a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/repository/sqlite/SqliteThingStore.java +++ b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/repository/sqlite/SqliteThingStore.java @@ -43,6 +43,7 @@ import uk.co.compendiumdev.thingifier.core.repository.RepositoryAdministration; import uk.co.compendiumdev.thingifier.core.repository.ThingStore; import uk.co.compendiumdev.thingifier.core.repository.ThingStoreTransaction; +import uk.co.compendiumdev.thingifier.core.repository.ThingStoreWriteException; import uk.co.compendiumdev.thingifier.core.repository.relationship.RelationshipEndpoint; import uk.co.compendiumdev.thingifier.core.repository.relationship.RelationshipRow; import uk.co.compendiumdev.thingifier.core.repository.relationship.RelationshipRules; @@ -563,10 +564,7 @@ private void prepareInstanceForInsert(final MutableEntityInstance instance) { if (entity.hasMaxInstanceLimit() && countInstances(entity) >= entity.getMaxInstanceLimit()) { - throw new RuntimeException( - String.format( - "ERROR: Cannot add instance, maximum limit of %d reached", - entity.getMaxInstanceLimit())); + throw ThingStoreWriteException.maxInstanceLimitReached(entity); } List explicitAutoIncrementFields = new ArrayList<>(); @@ -588,18 +586,14 @@ && countInstances(entity) >= entity.getMaxInstanceLimit()) { if (entity.hasPrimaryKeyField()) { Field primaryField = entity.getPrimaryKeyField(); if (!instance.hasInstantiatedFieldNamed(primaryField.getName())) { - throw new RuntimeException( - String.format( - "ERROR: Cannot add instance, primary key field %s not set", - primaryField.getName())); + throw ThingStoreWriteException.missingPrimaryKey(entity, primaryField.getName()); } EntityInstance existing = findInstanceByPrimaryKey(entity, instance.getPrimaryKeyValue()); if (existing != null && !existing.getInternalId().equals(instance.getInternalId())) { - throw new RuntimeException( - "ERROR: Cannot add instance, another instance with primary key value exists: " - + existing.getPrimaryKeyValue()); + throw ThingStoreWriteException.duplicatePrimaryKey( + entity, existing.getPrimaryKeyValue()); } } diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreContractTest.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreContractTest.java index a3b48f8e..49c3fc6c 100644 --- a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreContractTest.java +++ b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreContractTest.java @@ -208,6 +208,80 @@ public void sqliteRepositoryGeneratesAutoIdsThroughTheContract() { } } + @Test + public void sqliteRepositoryThrowsTypedMaxInstanceLimitFailure() { + ERSchema schema = ticketSchema(1); + EntityDefinition ticket = schema.getEntityDefinitionNamed("ticket"); + + try (ThingStore repository = + SqliteThingStore.inMemory(EntityRelModel.DEFAULT_DATABASE_NAME)) { + repository.administration().initializeFrom(schema); + createWithId(repository, ticket, "one"); + + ThingStoreWriteException exception = + Assertions.assertThrows( + ThingStoreWriteException.class, + () -> createWithId(repository, ticket, "two")); + + Assertions.assertEquals( + ThingStoreWriteException.Reason.MAX_INSTANCE_LIMIT_REACHED, exception.reason()); + Assertions.assertEquals( + "ERROR: Cannot add instance, maximum limit of 1 reached", + exception.getMessage()); + } + } + + @Test + public void sqliteRepositoryThrowsTypedDuplicatePrimaryKeyFailure() { + ERSchema schema = ticketSchema(-1); + EntityDefinition ticket = schema.getEntityDefinitionNamed("ticket"); + + try (ThingStore repository = + SqliteThingStore.inMemory(EntityRelModel.DEFAULT_DATABASE_NAME)) { + repository.administration().initializeFrom(schema); + createWithId(repository, ticket, "same"); + + ThingStoreWriteException exception = + Assertions.assertThrows( + ThingStoreWriteException.class, + () -> createWithId(repository, ticket, "same")); + + Assertions.assertEquals( + ThingStoreWriteException.Reason.DUPLICATE_PRIMARY_KEY, exception.reason()); + Assertions.assertEquals( + "ERROR: Cannot add instance, another instance with primary key value exists: " + + "same", + exception.getMessage()); + } + } + + @Test + public void sqliteRepositoryThrowsTypedMissingPrimaryKeyFailure() { + ERSchema schema = ticketSchema(-1); + EntityDefinition ticket = schema.getEntityDefinitionNamed("ticket"); + + try (ThingStore repository = + SqliteThingStore.inMemory(EntityRelModel.DEFAULT_DATABASE_NAME)) { + repository.administration().initializeFrom(schema); + + ThingStoreWriteException exception = + Assertions.assertThrows( + ThingStoreWriteException.class, + () -> + repository + .entities() + .create( + EntityInstanceDraft.forEntity(ticket) + .withField("title", "Missing id"))); + + Assertions.assertEquals( + ThingStoreWriteException.Reason.MISSING_PRIMARY_KEY, exception.reason()); + Assertions.assertEquals( + "ERROR: Cannot add instance, primary key field id not set", + exception.getMessage()); + } + } + @Test public void inMemoryRepositoryOwnsRelationshipValidationAndCascade() { ThingStore repository = new InMemoryThingStore(EntityRelModel.DEFAULT_DATABASE_NAME); @@ -854,6 +928,16 @@ private EntityInstance create( .create(EntityInstanceDraft.forEntity(entity).withField("title", title)); } + private EntityInstance createWithId( + final ThingStore repository, final EntityDefinition entity, final String id) { + return repository + .entities() + .create( + EntityInstanceDraft.forEntity(entity) + .withField("id", id) + .withField("title", "Title " + id)); + } + private String exportDataAsJson(final ThingStore repository, final ERSchema schema) { return new RepositoryJsonExporter(schema, repository.entityQueries()).asJson(); } @@ -870,6 +954,16 @@ private ERSchema autoIdSchema() { return schema; } + private ERSchema ticketSchema(final int maxInstances) { + ERSchema schema = new ERSchema(); + + EntityDefinition ticket = schema.defineEntity("ticket", "tickets", maxInstances); + ticket.addAsPrimaryKeyField(Field.is("id", FieldType.STRING)); + ticket.addField(Field.is("title", FieldType.STRING)); + + return schema; + } + private void assertExportedJsonContainsProjectAndTask(final String json) { Assertions.assertTrue(json.contains("\"projects\"")); Assertions.assertTrue(json.contains("\"tasks\"")); diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/inmemory/InMemoryEntityInstanceCollectionTest.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/inmemory/InMemoryEntityInstanceCollectionTest.java index 7645c562..331c1ac9 100644 --- a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/inmemory/InMemoryEntityInstanceCollectionTest.java +++ b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/inmemory/InMemoryEntityInstanceCollectionTest.java @@ -10,6 +10,7 @@ import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; import uk.co.compendiumdev.thingifier.core.repository.MutableEntityInstance; +import uk.co.compendiumdev.thingifier.core.repository.ThingStoreWriteException; public class InMemoryEntityInstanceCollectionTest { @@ -33,10 +34,12 @@ public void cannotCreateInstanceWithoutPrimaryKeySet() { MutableEntityInstance instance1 = MutableEntityInstance.fromDraft(EntityInstanceDraft.forEntity(entityDefn)); - Exception exception = + ThingStoreWriteException exception = Assertions.assertThrows( - RuntimeException.class, () -> collection.addInstance(instance1)); + ThingStoreWriteException.class, () -> collection.addInstance(instance1)); + Assertions.assertEquals( + ThingStoreWriteException.Reason.MISSING_PRIMARY_KEY, exception.reason()); Assertions.assertTrue( exception .getMessage() @@ -60,10 +63,12 @@ public void cannotCreateInstanceWithDuplicatePrimaryKey() { EntityInstanceDraft.forEntity(entityDefn) .withField("pk", instance1.getPrimaryKeyValue())); - Exception exception = + ThingStoreWriteException exception = Assertions.assertThrows( - RuntimeException.class, () -> collection.addInstance(instance2)); + ThingStoreWriteException.class, () -> collection.addInstance(instance2)); + Assertions.assertEquals( + ThingStoreWriteException.Reason.DUPLICATE_PRIMARY_KEY, exception.reason()); Assertions.assertTrue( exception.getMessage().contains("another instance with primary key value exists")); } diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/inmemory/ThingInstanceCardinalityCreationTest.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/inmemory/ThingInstanceCardinalityCreationTest.java index d2bb9515..229114a2 100644 --- a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/inmemory/ThingInstanceCardinalityCreationTest.java +++ b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/inmemory/ThingInstanceCardinalityCreationTest.java @@ -10,6 +10,7 @@ import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; import uk.co.compendiumdev.thingifier.core.repository.MutableEntityInstance; +import uk.co.compendiumdev.thingifier.core.repository.ThingStoreWriteException; public class ThingInstanceCardinalityCreationTest { @@ -44,13 +45,15 @@ public void cannotAddInstancesBeyondCardinalityLimit() { instances.addInstance(instance(entityDefn, "test3")); - Exception exception = + ThingStoreWriteException exception = Assertions.assertThrows( - RuntimeException.class, + ThingStoreWriteException.class, () -> { instances.addInstance(instance(entityDefn, "test4")); }); + Assertions.assertEquals( + ThingStoreWriteException.Reason.MAX_INSTANCE_LIMIT_REACHED, exception.reason()); Assertions.assertEquals( "ERROR: Cannot add instance, maximum limit of 3 reached", exception.getMessage()); Assertions.assertEquals(3, instances.countInstances()); @@ -66,13 +69,15 @@ public void cannotAddInstancesBeyondCardinalityLimitOfOne() { instances.addInstance(instance(entityDefn, "test1")); - Exception exception = + ThingStoreWriteException exception = Assertions.assertThrows( - RuntimeException.class, + ThingStoreWriteException.class, () -> { instances.addInstance(instance(entityDefn, "test2")); }); + Assertions.assertEquals( + ThingStoreWriteException.Reason.MAX_INSTANCE_LIMIT_REACHED, exception.reason()); Assertions.assertEquals( "ERROR: Cannot add instance, maximum limit of 1 reached", exception.getMessage()); Assertions.assertEquals(1, instances.countInstances()); @@ -94,13 +99,16 @@ public void cannotAddMultiplesInstancesBeyondCardinality() { toAdd.add(instance(entityDefn, "test3")); toAdd.add(instance(entityDefn, "test4")); - Exception exception = + ThingStoreWriteException exception = Assertions.assertThrows( - RuntimeException.class, + ThingStoreWriteException.class, () -> { instances.addInstances(toAdd); }); + Assertions.assertEquals( + ThingStoreWriteException.Reason.MAX_INSTANCE_LIMIT_WOULD_BE_EXCEEDED, + exception.reason()); Assertions.assertEquals( "ERROR: Cannot add instances, would exceed maximum limit of 3", exception.getMessage()); diff --git a/standAloneTodoListManagerRestApiAuto/src/test/java/uk/co/compendiumdev/version4/todos/TodoEntityTest.java b/standAloneTodoListManagerRestApiAuto/src/test/java/uk/co/compendiumdev/version4/todos/TodoEntityTest.java index c7a49382..2cca65ff 100644 --- a/standAloneTodoListManagerRestApiAuto/src/test/java/uk/co/compendiumdev/version4/todos/TodoEntityTest.java +++ b/standAloneTodoListManagerRestApiAuto/src/test/java/uk/co/compendiumdev/version4/todos/TodoEntityTest.java @@ -55,7 +55,7 @@ void titleIsMandatoryOnCreateTodo() { final Response response = Api.createTodo(todo); - Assertions.assertEquals(400, response.getStatusCode()); + Assertions.assertEquals(422, response.getStatusCode()); final Payloads.ErrorMessageResponse errors = response.body().as(Payloads.ErrorMessageResponse.class); diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ThingCommandResultApiMapper.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ThingCommandResultApiMapper.java index 3f7d4e42..15e4fb4f 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ThingCommandResultApiMapper.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ThingCommandResultApiMapper.java @@ -112,6 +112,9 @@ public static int statusFor(final ApplicationError error) { if (error.category() == ApplicationError.Category.CONFLICT) { return 409; } + if (error.category() == ApplicationError.Category.VALIDATION) { + return 422; + } return 400; } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGenerator.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGenerator.java index ab172bcb..ec4e512a 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGenerator.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGenerator.java @@ -145,7 +145,18 @@ public ApiRoutingDefinition generate(String apiPathPrefix) { RoutingStatus.returnValue( 400, String.format( - "Error when creating a %s", entityDefn.getName()))); + "Error when creating a %s", entityDefn.getName()))) + .addPossibleStatus( + RoutingStatus.returnValue( + 422, + String.format( + "Validation error when creating a %s", + entityDefn.getName()))) + .addPossibleStatus( + RoutingStatus.returnValue( + 409, + String.format( + "Conflict when creating a %s", entityDefn.getName()))); // TODO: allow configurable 200 for options defn.addRouting( @@ -236,7 +247,18 @@ public ApiRoutingDefinition generate(String apiPathPrefix) { RoutingStatus.returnValue( 404, String.format( - "Could not find a specific %s", entityDefn.getName()))); + "Could not find a specific %s", entityDefn.getName()))) + .addPossibleStatus( + RoutingStatus.returnValue( + 422, + String.format( + "Validation error when amending a %s", + entityDefn.getName()))) + .addPossibleStatus( + RoutingStatus.returnValue( + 409, + String.format( + "Conflict when amending a %s", entityDefn.getName()))); ; // we should be able to amend things with PUT and a GUID e.g. PUT project/GUID @@ -268,7 +290,18 @@ public ApiRoutingDefinition generate(String apiPathPrefix) { RoutingStatus.returnValue( 404, String.format( - "Could not find a specific %s", entityDefn.getName()))); + "Could not find a specific %s", entityDefn.getName()))) + .addPossibleStatus( + RoutingStatus.returnValue( + 422, + String.format( + "Validation error when replacing a %s", + entityDefn.getName()))) + .addPossibleStatus( + RoutingStatus.returnValue( + 409, + String.format( + "Conflict when replacing a %s", entityDefn.getName()))); ; // we should be able to delete specific things e.g. DELETE project/GUID @@ -418,7 +451,17 @@ private void addRoutingsForRelationship( RoutingStatus.returnValue(201, String.format("created the relationship"))) .addPossibleStatus( RoutingStatus.returnValue( - 400, String.format("error when creating the relationship"))); + 400, String.format("error when creating the relationship"))) + .addPossibleStatus( + RoutingStatus.returnValue( + 404, String.format("relationship source or target not found"))) + .addPossibleStatus( + RoutingStatus.returnValue( + 422, + String.format("validation error when creating the relationship"))) + .addPossibleStatus( + RoutingStatus.returnValue( + 409, String.format("conflict when creating the relationship"))); defn.addRouting( "method not allowed", RoutingVerb.DELETE, aUrl, RoutingStatus.returnValue(405)); @@ -449,7 +492,14 @@ private void addRoutingsForRelationship( RoutingStatus.returnValue( 400, String.format("error when deleting the relationship"))) .addPossibleStatus( - RoutingStatus.returnValue(404, String.format("relationship not found"))); + RoutingStatus.returnValue(404, String.format("relationship not found"))) + .addPossibleStatus( + RoutingStatus.returnValue( + 422, + String.format("validation error when deleting the relationship"))) + .addPossibleStatus( + RoutingStatus.returnValue( + 409, String.format("conflict when deleting the relationship"))); defn.addRouting( String.format("show all Options for endpoint of %s", aUrlDelete), diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/AmendThingHandler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/AmendThingHandler.java index d8cebcf0..18890a8a 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/AmendThingHandler.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/AmendThingHandler.java @@ -9,6 +9,7 @@ import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; import uk.co.compendiumdev.thingifier.core.repository.ThingStore; +import uk.co.compendiumdev.thingifier.core.repository.ThingStoreWriteException; final class AmendThingHandler { @@ -61,6 +62,8 @@ ThingCommandResult handle(final AmendThingCommand command) { draft, command.shouldReplaceExistingFieldsAndRelationships(), command.getRelationships()); + } catch (ThingStoreWriteException e) { + throw e; } catch (Exception e) { return ThingCommandResult.error(ApplicationExceptionMessages.messageFrom(e)); } @@ -84,6 +87,8 @@ ThingCommandResult handle(final ReplaceThingCommand command) { EntityInstanceDraft draft = new EntityInstanceDraftBuilder(instance).setFieldValuesFrom(fieldValues); return amend(instance, draft, true, command.getRelationships()); + } catch (ThingStoreWriteException e) { + throw e; } catch (Exception e) { return ThingCommandResult.error(ApplicationExceptionMessages.messageFrom(e)); } @@ -104,6 +109,8 @@ ThingCommandResult handle(final ReplaceThingCommand command) { return created; } return ThingCommandResult.created(created.getInstance()); + } catch (ThingStoreWriteException e) { + throw e; } catch (Exception e) { return ThingCommandResult.error(ApplicationExceptionMessages.messageFrom(e)); } @@ -136,6 +143,8 @@ private ThingCommandResult amend( } return ThingCommandResult.success(updated); + } catch (ThingStoreWriteException e) { + throw e; } catch (Exception e) { return ThingCommandResult.error(ApplicationExceptionMessages.messageFrom(e)); } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/ApplicationError.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/ApplicationError.java index 40beefb9..9b7d5a4f 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/ApplicationError.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/ApplicationError.java @@ -26,7 +26,12 @@ public enum Code { CONFLICT, UNSUPPORTED_COMMAND, REPLACE_CREATE_AUTO_FIELDS_NOT_ALLOWED, - REPLACE_CREATE_KEY_MISMATCH + REPLACE_CREATE_KEY_MISMATCH, + MAX_INSTANCE_LIMIT_REACHED, + MAX_INSTANCE_LIMIT_WOULD_BE_EXCEEDED, + DUPLICATE_PRIMARY_KEY, + MISSING_PRIMARY_KEY, + STORE_WRITE_VALIDATION_FAILED } private final Category category; @@ -121,6 +126,39 @@ public static ApplicationError conflict(final String message) { return new ApplicationError(Category.CONFLICT, Code.CONFLICT, List.of(message), Map.of()); } + public static ApplicationError maxInstanceLimitReached( + final String message, final Map details) { + return new ApplicationError( + Category.CONFLICT, Code.MAX_INSTANCE_LIMIT_REACHED, List.of(message), details); + } + + public static ApplicationError maxInstanceLimitWouldBeExceeded( + final String message, final Map details) { + return new ApplicationError( + Category.CONFLICT, + Code.MAX_INSTANCE_LIMIT_WOULD_BE_EXCEEDED, + List.of(message), + details); + } + + public static ApplicationError duplicatePrimaryKey( + final String message, final Map details) { + return new ApplicationError( + Category.CONFLICT, Code.DUPLICATE_PRIMARY_KEY, List.of(message), details); + } + + public static ApplicationError missingPrimaryKey( + final String message, final Map details) { + return new ApplicationError( + Category.VALIDATION, Code.MISSING_PRIMARY_KEY, List.of(message), details); + } + + public static ApplicationError storeWriteValidationFailed( + final String message, final Map details) { + return new ApplicationError( + Category.VALIDATION, Code.STORE_WRITE_VALIDATION_FAILED, List.of(message), details); + } + public static ApplicationError unsupported(final String message) { return new ApplicationError( Category.UNSUPPORTED, Code.UNSUPPORTED_COMMAND, List.of(message), Map.of()); diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/CreateThingHandler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/CreateThingHandler.java index 164e33ad..986f5be9 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/CreateThingHandler.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/CreateThingHandler.java @@ -8,6 +8,7 @@ import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; import uk.co.compendiumdev.thingifier.core.repository.ThingStore; +import uk.co.compendiumdev.thingifier.core.repository.ThingStoreWriteException; final class CreateThingHandler { @@ -62,6 +63,8 @@ ThingCommandResult handle(final CreateThingCommand command) { drafts.createDraft(entity, command.getRequestedPrimaryKey(), fieldValues); return create( draft, command.getRelationships(), command.shouldValidateFinalRelationships()); + } catch (ThingStoreWriteException e) { + throw e; } catch (Exception e) { return ThingCommandResult.error(ApplicationExceptionMessages.messageFrom(e)); } @@ -80,6 +83,8 @@ ThingCommandResult create( return relationshipResult; } return ThingCommandResult.success(created); + } catch (ThingStoreWriteException e) { + throw e; } catch (Exception e) { return ThingCommandResult.error(ApplicationExceptionMessages.messageFrom(e)); } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/DeleteThingHandler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/DeleteThingHandler.java index baf977ea..c757319f 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/DeleteThingHandler.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/DeleteThingHandler.java @@ -4,6 +4,7 @@ import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; import uk.co.compendiumdev.thingifier.core.repository.ThingStore; +import uk.co.compendiumdev.thingifier.core.repository.ThingStoreWriteException; final class DeleteThingHandler { @@ -27,6 +28,8 @@ ThingCommandResult handle(final DeleteThingCommand command) { try { store.entities().delete(instance); return ThingCommandResult.success(); + } catch (ThingStoreWriteException e) { + throw e; } catch (Exception e) { return ThingCommandResult.error(ApplicationExceptionMessages.messageFrom(e)); } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/RelationshipCommandHandler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/RelationshipCommandHandler.java index defa454f..a1f44eb1 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/RelationshipCommandHandler.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/RelationshipCommandHandler.java @@ -11,6 +11,7 @@ import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; import uk.co.compendiumdev.thingifier.core.repository.ThingStore; +import uk.co.compendiumdev.thingifier.core.repository.ThingStoreWriteException; final class RelationshipCommandHandler { @@ -115,6 +116,8 @@ ThingCommandResult handle(final CreateAndConnectRelationshipCommand command) { } return ThingCommandResult.success(createResult.getInstance()); + } catch (ThingStoreWriteException e) { + throw e; } catch (Exception e) { return ThingCommandResult.error(ApplicationExceptionMessages.messageFrom(e)); } @@ -201,6 +204,8 @@ ThingCommandResult handle(final DisconnectRelationshipCommand command) { try { store.relationships().removeBetween(parent, child, command.getRelationshipName()); return ThingCommandResult.success(); + } catch (ThingStoreWriteException e) { + throw e; } catch (Exception e) { return ThingCommandResult.error(ApplicationExceptionMessages.messageFrom(e)); } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/RelationshipConnectionService.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/RelationshipConnectionService.java index 998795d3..de501494 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/RelationshipConnectionService.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/RelationshipConnectionService.java @@ -7,6 +7,7 @@ import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; import uk.co.compendiumdev.thingifier.core.reporting.ValidationReport; import uk.co.compendiumdev.thingifier.core.repository.ThingStore; +import uk.co.compendiumdev.thingifier.core.repository.ThingStoreWriteException; final class RelationshipConnectionService { @@ -41,6 +42,8 @@ ThingCommandResult connectRelationship( } return ThingCommandResult.success(child); + } catch (ThingStoreWriteException e) { + throw e; } catch (Exception e) { if (!alreadyConnected) { store.relationships().disconnectBetween(parent, child, relationshipName); @@ -127,6 +130,8 @@ private ThingCommandResult connectRelationships( } return ThingCommandResult.success(instance); + } catch (ThingStoreWriteException e) { + throw e; } catch (Exception e) { disconnectConnections(instance, connectedByCommand); return ThingCommandResult.error( diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/WriteTransactionRunner.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/WriteTransactionRunner.java index 408d7646..6f3e2dba 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/WriteTransactionRunner.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/WriteTransactionRunner.java @@ -3,6 +3,7 @@ import java.util.function.Supplier; import uk.co.compendiumdev.thingifier.core.repository.ThingStore; import uk.co.compendiumdev.thingifier.core.repository.ThingStoreTransaction; +import uk.co.compendiumdev.thingifier.core.repository.ThingStoreWriteException; final class WriteTransactionRunner { @@ -21,11 +22,32 @@ ThingCommandResult run(final Supplier operation) { transaction.rollback(); } return result; + } catch (ThingStoreWriteException e) { + return ThingCommandResult.error(errorFrom(e)); } catch (Exception e) { return ThingCommandResult.error(messageFrom(e)); } } + private ApplicationError errorFrom(final ThingStoreWriteException exception) { + return switch (exception.reason()) { + case MAX_INSTANCE_LIMIT_REACHED -> + ApplicationError.maxInstanceLimitReached( + messageFrom(exception), exception.details()); + case MAX_INSTANCE_LIMIT_WOULD_BE_EXCEEDED -> + ApplicationError.maxInstanceLimitWouldBeExceeded( + messageFrom(exception), exception.details()); + case DUPLICATE_PRIMARY_KEY -> + ApplicationError.duplicatePrimaryKey( + messageFrom(exception), exception.details()); + case MISSING_PRIMARY_KEY -> + ApplicationError.missingPrimaryKey(messageFrom(exception), exception.details()); + case WRONG_ENTITY_TYPE -> + ApplicationError.storeWriteValidationFailed( + messageFrom(exception), exception.details()); + }; + } + private String messageFrom(final Exception exception) { String message = exception.getMessage(); return message == null ? "" : message; diff --git a/thingifier/src/test/java/uk/co/compendiumdev/api_non_http/SimpleVerbPutEntityInstanceApiNonHttpTest.java b/thingifier/src/test/java/uk/co/compendiumdev/api_non_http/SimpleVerbPutEntityInstanceApiNonHttpTest.java index 41ef3375..b1e47fb7 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/api_non_http/SimpleVerbPutEntityInstanceApiNonHttpTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/api_non_http/SimpleVerbPutEntityInstanceApiNonHttpTest.java @@ -201,7 +201,7 @@ public void putFailCanNotAmendExistingInstancesWhenMandatoryFieldsAreMissing() { String.format("entities/%s", officeWork.getPrimaryKeyValue()), getSimpleParser(requestBody, thingifier), new HttpHeadersBlock()); - Assertions.assertEquals(400, apiresponse.getStatusCode()); + Assertions.assertEquals(422, apiresponse.getStatusCode()); Assertions.assertTrue( apiresponse.getErrorMessages().stream() .anyMatch(error -> error.contains("title : field is mandatory"))); @@ -252,7 +252,7 @@ public void putFailCanNotAmendPrimaryKey() { getSimpleParser(requestBody, thingifier), new HttpHeadersBlock()); - Assertions.assertEquals(400, apiresponse.getStatusCode()); + Assertions.assertEquals(422, apiresponse.getStatusCode()); Assertions.assertTrue( apiresponse.getErrorMessages().contains("Can not amend id from 1 to 22")); Assertions.assertEquals( @@ -301,7 +301,7 @@ public void putFailCanNotCreateAnEntityInstanceWithAutoFields() { String.format("entities/%s", id), getSimpleParser(requestBody, thingifier), new HttpHeadersBlock()); - Assertions.assertEquals(400, apiresponse.getStatusCode()); + Assertions.assertEquals(422, apiresponse.getStatusCode()); Assertions.assertEquals( currentinstances, @@ -370,7 +370,7 @@ public void putFailCanNotAmendAutoGuid() { getSimpleParser(requestBody, myThingifier), new HttpHeadersBlock()); - Assertions.assertEquals(400, apiresponse.getStatusCode()); + Assertions.assertEquals(422, apiresponse.getStatusCode()); Assertions.assertTrue( apiresponse .getErrorMessages() @@ -563,7 +563,7 @@ public void putFailCanNotCreateAnInstanceWhenKeyDoesNotMatchBody() { getSimpleParser(requestBody, myThingifier), new HttpHeadersBlock()); - Assertions.assertEquals(400, apiresponse.getStatusCode()); + Assertions.assertEquals(422, apiresponse.getStatusCode()); Assertions.assertEquals( "Cannot create entity with PUT as key does not match body value newkey != innerkey", apiresponse.getErrorMessages().toArray()[0]); diff --git a/thingifier/src/test/java/uk/co/compendiumdev/casestudy/todomanager/api_non_http/RelationshipApiNonHttpTest.java b/thingifier/src/test/java/uk/co/compendiumdev/casestudy/todomanager/api_non_http/RelationshipApiNonHttpTest.java index dafc160b..ec565f5d 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/casestudy/todomanager/api_non_http/RelationshipApiNonHttpTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/casestudy/todomanager/api_non_http/RelationshipApiNonHttpTest.java @@ -1097,7 +1097,7 @@ public void postCanNotCreateEstimateWithoutAMandatoryRelationshipUsingAPI() { todoManager .api() .post("estimate", getSimpleParser(requestBody), new HttpHeadersBlock()); - Assertions.assertEquals(400, apiresponse.getStatusCode()); + Assertions.assertEquals(422, apiresponse.getStatusCode()); Assertions.assertEquals( numberOfEstimates, diff --git a/thingifier/src/test/java/uk/co/compendiumdev/casestudy/todomanager/api_non_http/RelationshipApiSqliteRepositoryTest.java b/thingifier/src/test/java/uk/co/compendiumdev/casestudy/todomanager/api_non_http/RelationshipApiSqliteRepositoryTest.java index beeb0c2c..a759e962 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/casestudy/todomanager/api_non_http/RelationshipApiSqliteRepositoryTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/casestudy/todomanager/api_non_http/RelationshipApiSqliteRepositoryTest.java @@ -290,7 +290,7 @@ public void failedRelationshipPathDoesNotDeleteExistingRelatedItem() { parserFor(todoManager, body), new HttpHeadersBlock()); - Assertions.assertEquals(400, response.getStatusCode()); + Assertions.assertEquals(422, response.getStatusCode()); Assertions.assertNotNull( repository .entityQueries() @@ -315,7 +315,7 @@ public void failedRootCreateDoesNotPersistEntityWhenRelationshipInvariantFails() .api() .post("todo", parserFor(todoManager, body), new HttpHeadersBlock()); - Assertions.assertEquals(400, response.getStatusCode()); + Assertions.assertEquals(422, response.getStatusCode()); Assertions.assertEquals(todoCount, repository.entityQueries().count(todo)); } } @@ -402,7 +402,7 @@ public void failedRelationshipAmendRestoresFieldsAndRelationships() { List relatedProjects = repository.relationships().listRelated(restoredTask, "task-of"); - Assertions.assertEquals(400, response.getStatusCode()); + Assertions.assertEquals(422, response.getStatusCode()); Assertions.assertEquals( "Original title", restoredTask.getFieldValue("title").asString()); Assertions.assertEquals(1, relatedProjects.size()); diff --git a/thingifier/src/test/java/uk/co/compendiumdev/casestudy/todomanager/api_non_http/VerbPostEntityInstanceApiNonHttpTest.java b/thingifier/src/test/java/uk/co/compendiumdev/casestudy/todomanager/api_non_http/VerbPostEntityInstanceApiNonHttpTest.java index 71f2b17e..3290284f 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/casestudy/todomanager/api_non_http/VerbPostEntityInstanceApiNonHttpTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/casestudy/todomanager/api_non_http/VerbPostEntityInstanceApiNonHttpTest.java @@ -13,6 +13,8 @@ import uk.co.compendiumdev.thingifier.api.response.ApiResponse; import uk.co.compendiumdev.thingifier.core.EntityRelModel; import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; @@ -101,6 +103,35 @@ public void postCanCreateAnEntityWhichPassesValidationWithAllFields() { Assertions.assertEquals(createdProject, createdInstance); } + @Test + public void postFailsWithConflictWhenMaximumInstanceLimitIsReached() { + Thingifier limitedThingifier = new Thingifier(); + EntityDefinition ticket = limitedThingifier.defineThing("ticket", "tickets", 1); + ticket.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT)); + ticket.addField(Field.is("title", FieldType.STRING)); + + Map first = new HashMap<>(); + first.put("title", "First"); + limitedThingifier + .api() + .post("tickets", parserFor(limitedThingifier, first), new HttpHeadersBlock()); + + Map second = new HashMap<>(); + second.put("title", "Second"); + ApiResponse apiresponse = + limitedThingifier + .api() + .post( + "tickets", + parserFor(limitedThingifier, second), + new HttpHeadersBlock()); + + Assertions.assertEquals(409, apiresponse.getStatusCode()); + Assertions.assertEquals( + "ERROR: Cannot add instance, maximum limit of 1 reached", + apiresponse.getErrorMessages().iterator().next()); + } + private BodyParser getSimpleParser(final Map requestBody) { final HttpApiRequest arequest = @@ -108,6 +139,13 @@ private BodyParser getSimpleParser(final Map requestBody) { return new BodyParser(arequest, todoManager.getThingNames()); } + private BodyParser parserFor( + final Thingifier thingifier, final Map requestBody) { + final HttpApiRequest request = + new HttpApiRequest("/path").setBody(new Gson().toJson(requestBody)); + return new BodyParser(request, thingifier.getThingNames()); + } + @Test public void postCanCreateAnEntityWhichPassesValidationWithMinimumFields() { @@ -261,7 +299,7 @@ public void postFailCannotAmendEntityInstanceWhenValidationErrorsAPI() { getSimpleParser(requestBody), new HttpHeadersBlock()); - Assertions.assertEquals(400, apiresponse.getStatusCode()); + Assertions.assertEquals(422, apiresponse.getStatusCode()); Assertions.assertFalse(apiresponse.getErrorMessages().isEmpty()); Assertions.assertTrue(apiresponse.hasABody()); @@ -281,7 +319,7 @@ public void postFailCannotAmendEntityInstanceWhenValidationErrorsAPI() { getSimpleParser(requestBody), new HttpHeadersBlock()); - Assertions.assertEquals(400, apiresponse.getStatusCode()); + Assertions.assertEquals(422, apiresponse.getStatusCode()); Assertions.assertTrue(apiresponse.getErrorMessages().size() > 0); Assertions.assertTrue(apiresponse.hasABody()); @@ -354,7 +392,7 @@ public void postFailCannotCreateEntityInstanceWhenValidationErrorsAPI() { // Mandatory field validation on POST create - must have a title requestBody = new HashMap<>(); - requestBody.put("description", "A new TODO Item"); // 400 because it should be "title" + requestBody.put("description", "A new TODO Item"); // 422 because it should be "title" apiresponse = todoManager @@ -363,7 +401,7 @@ public void postFailCannotCreateEntityInstanceWhenValidationErrorsAPI() { String.format("todo"), getSimpleParser(requestBody), new HttpHeadersBlock()); - Assertions.assertEquals(400, apiresponse.getStatusCode()); + Assertions.assertEquals(422, apiresponse.getStatusCode()); Assertions.assertFalse(apiresponse.getErrorMessages().isEmpty()); Assertions.assertEquals( "title : field is mandatory", apiresponse.getErrorMessages().iterator().next()); @@ -381,7 +419,7 @@ public void postFailCannotCreateEntityInstanceWhenValidationErrorsAPI() { String.format("todo"), getSimpleParser(requestBody), new HttpHeadersBlock()); - Assertions.assertEquals(400, apiresponse.getStatusCode()); + Assertions.assertEquals(422, apiresponse.getStatusCode()); Assertions.assertFalse(apiresponse.getErrorMessages().isEmpty()); Assertions.assertTrue(apiresponse.hasABody()); @@ -405,7 +443,7 @@ public void postFailCannotCreateEntityInstanceWhenValidationErrorsAPI() { String.format("todo/%s", paperwork.getPrimaryKeyValue()), getSimpleParser(requestBody), new HttpHeadersBlock()); - Assertions.assertEquals(400, apiresponse.getStatusCode()); + Assertions.assertEquals(422, apiresponse.getStatusCode()); Assertions.assertFalse(apiresponse.getErrorMessages().isEmpty()); Assertions.assertTrue(apiresponse.hasABody()); } diff --git a/thingifier/src/test/java/uk/co/compendiumdev/casestudy/todomanager/api_non_http/VerbPutEntityInstanceApiNonHttpTest.java b/thingifier/src/test/java/uk/co/compendiumdev/casestudy/todomanager/api_non_http/VerbPutEntityInstanceApiNonHttpTest.java index 9a5cc82f..d081eeb5 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/casestudy/todomanager/api_non_http/VerbPutEntityInstanceApiNonHttpTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/casestudy/todomanager/api_non_http/VerbPutEntityInstanceApiNonHttpTest.java @@ -201,7 +201,7 @@ public void putCanNotAmendGUID() { getSimpleParser(requestBody), new HttpHeadersBlock()); - Assertions.assertEquals(400, apiresponse.getStatusCode()); + Assertions.assertEquals(422, apiresponse.getStatusCode()); Assertions.assertEquals( "An Existing Project", officeWork.getFieldValue("title").asString()); Assertions.assertEquals( @@ -342,7 +342,7 @@ public void putCanNotCreateAnEntityInstanceWithADuplicateId() { String.format("project/%s", guid), getSimpleParser(requestBody), new HttpHeadersBlock()); - Assertions.assertEquals(400, apiresponse.getStatusCode()); + Assertions.assertEquals(422, apiresponse.getStatusCode()); Assertions.assertEquals( 1, @@ -360,7 +360,7 @@ public void putFailValidationEntityInstanceAPI() { // Mandatory field validation PUT create requestBody = new HashMap(); - // will generate 400 because description should be title + // will generate 422 because description should be title requestBody.put("description", "A new TODO Item"); requestBody.put("doneStatus", "TRUE"); apiresponse = @@ -370,7 +370,7 @@ public void putFailValidationEntityInstanceAPI() { String.format("todo/%s", UUID.randomUUID().toString()), getSimpleParser(requestBody), new HttpHeadersBlock()); - Assertions.assertEquals(400, apiresponse.getStatusCode()); + Assertions.assertEquals(422, apiresponse.getStatusCode()); Assertions.assertTrue(apiresponse.getErrorMessages().size() > 0); Assertions.assertTrue(apiresponse.hasABody()); @@ -384,7 +384,7 @@ public void putFailValidationEntityInstanceAPI() { // Mandatory field validation PUT amend requestBody = new HashMap(); - // will generate 400 because description should be title + // will generate 422 because description should be title requestBody.put("description", "Amended TODO Item "); requestBody.put("doneStatus", "TRUE"); apiresponse = @@ -394,7 +394,7 @@ public void putFailValidationEntityInstanceAPI() { String.format("todo/%s", paperwork.getPrimaryKeyValue()), getSimpleParser(requestBody), new HttpHeadersBlock()); - Assertions.assertEquals(400, apiresponse.getStatusCode()); + Assertions.assertEquals(422, apiresponse.getStatusCode()); Assertions.assertTrue(apiresponse.getErrorMessages().size() > 0); Assertions.assertTrue(apiresponse.hasABody()); @@ -410,7 +410,7 @@ public void putFailValidationEntityInstanceAPI() { String.format("todo/%s", paperwork.getPrimaryKeyValue()), getSimpleParser(requestBody), new HttpHeadersBlock()); - Assertions.assertEquals(400, apiresponse.getStatusCode()); + Assertions.assertEquals(422, apiresponse.getStatusCode()); Assertions.assertTrue(apiresponse.getErrorMessages().size() > 0); Assertions.assertTrue(apiresponse.hasABody()); } @@ -423,7 +423,7 @@ public void putFailCannotCreateEntityInstanceWhenValidationErrors() { // Mandatory field validation PUT create requestBody = new HashMap(); - // will generate 400 because description should be title + // will generate 422 because description should be title requestBody.put("description", "A new TODO Item"); requestBody.put("doneStatus", "TRUE"); apiresponse = @@ -433,7 +433,7 @@ public void putFailCannotCreateEntityInstanceWhenValidationErrors() { String.format("todo/%s", UUID.randomUUID().toString()), getSimpleParser(requestBody), new HttpHeadersBlock()); - Assertions.assertEquals(400, apiresponse.getStatusCode()); + Assertions.assertEquals(422, apiresponse.getStatusCode()); Assertions.assertTrue(apiresponse.getErrorMessages().size() > 0); Assertions.assertTrue(apiresponse.hasABody()); } @@ -454,7 +454,7 @@ public void putFailCannotAmendEntityInstanceWhenValidationErrors() { // Mandatory field validation PUT amend requestBody = new HashMap(); - // will generate 400 because description should be title + // will generate 422 because description should be title requestBody.put("description", "Amended TODO Item "); requestBody.put("doneStatus", "TRUE"); apiresponse = @@ -464,7 +464,7 @@ public void putFailCannotAmendEntityInstanceWhenValidationErrors() { String.format("todo/%s", paperwork.getPrimaryKeyValue()), getSimpleParser(requestBody), new HttpHeadersBlock()); - Assertions.assertEquals(400, apiresponse.getStatusCode()); + Assertions.assertEquals(422, apiresponse.getStatusCode()); Assertions.assertTrue(apiresponse.getErrorMessages().size() > 0); Assertions.assertTrue(apiresponse.hasABody()); @@ -480,7 +480,7 @@ public void putFailCannotAmendEntityInstanceWhenValidationErrors() { String.format("todo/%s", paperwork.getPrimaryKeyValue()), getSimpleParser(requestBody), new HttpHeadersBlock()); - Assertions.assertEquals(400, apiresponse.getStatusCode()); + Assertions.assertEquals(422, apiresponse.getStatusCode()); Assertions.assertTrue(apiresponse.getErrorMessages().size() > 0); Assertions.assertTrue(apiresponse.hasABody()); } diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGeneratorTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGeneratorTest.java new file mode 100644 index 00000000..3f052ca0 --- /dev/null +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGeneratorTest.java @@ -0,0 +1,83 @@ +package uk.co.compendiumdev.thingifier.api.docgen; + +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.core.domain.definitions.Cardinality; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; + +public class ApiRoutingDefinitionDocGeneratorTest { + + @Test + public void entityCollectionPostDocumentsValidationAndConflictStatuses() { + ApiRoutingDefinition definition = + new ApiRoutingDefinitionDocGenerator(model()).generate(""); + + RoutingDefinition route = route(definition, RoutingVerb.POST, "todos"); + + Assertions.assertTrue(statuses(route).containsAll(Set.of(201, 400, 422, 409))); + } + + @Test + public void entityInstanceWritesDocumentValidationAndConflictStatuses() { + ApiRoutingDefinition definition = + new ApiRoutingDefinitionDocGenerator(model()).generate(""); + + Assertions.assertTrue( + statuses(route(definition, RoutingVerb.POST, "todos/:id")) + .containsAll(Set.of(200, 404, 422, 409))); + Assertions.assertTrue( + statuses(route(definition, RoutingVerb.PUT, "todos/:id")) + .containsAll(Set.of(200, 404, 422, 409))); + } + + @Test + public void relationshipWritesDocumentValidationAndConflictStatuses() { + ApiRoutingDefinition definition = + new ApiRoutingDefinitionDocGenerator(model()).generate(""); + + Assertions.assertTrue( + statuses(route(definition, RoutingVerb.POST, "projects/:id/tasks")) + .containsAll(Set.of(201, 400, 404, 422, 409))); + Assertions.assertTrue( + statuses(route(definition, RoutingVerb.DELETE, "projects/:id/tasks/:id")) + .containsAll(Set.of(200, 400, 404, 422, 409))); + } + + private Thingifier model() { + Thingifier thingifier = new Thingifier(); + + EntityDefinition project = thingifier.defineThing("project", "projects", 1); + project.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT)); + project.addField(Field.is("title", FieldType.STRING)); + + EntityDefinition todo = thingifier.defineThing("todo", "todos", 1); + todo.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT)); + todo.addField(Field.is("title", FieldType.STRING).makeMandatory()); + + thingifier + .defineRelationship(project, todo, "tasks", Cardinality.ONE_TO_MANY()) + .whenReversed(Cardinality.ONE_TO_MANY(), "tasksof"); + + return thingifier; + } + + private RoutingDefinition route( + final ApiRoutingDefinition definition, final RoutingVerb verb, final String url) { + return definition.definitions().stream() + .filter(route -> route.verb() == verb) + .filter(route -> route.url().equals(url)) + .findFirst() + .orElseThrow(); + } + + private Set statuses(final RoutingDefinition route) { + return route.getPossibleStatusReponses().stream() + .map(RoutingStatus::value) + .collect(Collectors.toSet()); + } +} diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/non_http/NestedObjectsApiTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/non_http/NestedObjectsApiTest.java index 7e3b388a..0d9e8ad4 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/non_http/NestedObjectsApiTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/non_http/NestedObjectsApiTest.java @@ -145,7 +145,7 @@ public void failValidationAtObjectFieldLevel() { final HttpApiResponse response = api.post(failToCreateBobRequest); - Assertions.assertEquals(400, response.getStatusCode()); + Assertions.assertEquals(422, response.getStatusCode()); Assertions.assertEquals( 0, thingifier diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingCommandResultApiMapperTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingCommandResultApiMapperTest.java index 86b1203a..672261fb 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingCommandResultApiMapperTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingCommandResultApiMapperTest.java @@ -27,7 +27,7 @@ public void mapsReplaceCreateAutoFieldErrorToLegacyPutMessage() { ApiResponse response = mapper().map(new ReplaceThingCommand("entity", "1", List.of(), List.of()), result); - Assertions.assertEquals(400, response.getStatusCode()); + Assertions.assertEquals(422, response.getStatusCode()); Assertions.assertEquals( List.of("Cannot create entity with PUT due to Auto fields id"), response.getErrorMessages()); @@ -44,7 +44,7 @@ public void mapsReplaceCreateKeyMismatchErrorToLegacyPutMessage() { new ReplaceThingCommand("entity", "newkey", List.of(), List.of()), result); - Assertions.assertEquals(400, response.getStatusCode()); + Assertions.assertEquals(422, response.getStatusCode()); Assertions.assertEquals( List.of( "Cannot create entity with PUT as key does not match body value " @@ -73,7 +73,7 @@ public void mapsConflictWithoutValidationPrefix() { public void mapsApplicationCategoriesToHttpStatuses() { Assertions.assertEquals(400, ThingCommandResultApiMapper.statusFor(null)); Assertions.assertEquals( - 400, ThingCommandResultApiMapper.statusFor(ApplicationError.validation("bad"))); + 422, ThingCommandResultApiMapper.statusFor(ApplicationError.validation("bad"))); Assertions.assertEquals( 404, ThingCommandResultApiMapper.statusFor(ApplicationError.notFound("missing"))); Assertions.assertEquals( @@ -83,6 +83,19 @@ public void mapsApplicationCategoriesToHttpStatuses() { ThingCommandResultApiMapper.statusFor(ApplicationError.unsupported("unknown"))); } + @Test + public void mapsCreateValidationWithExistingLegacyPrefixBehaviour() { + ThingCommandResult result = ThingCommandResult.error("title : field is mandatory"); + + ApiResponse response = + mapper().map(new CreateThingCommand("entity", List.of(), List.of(), true), result); + + Assertions.assertEquals(422, response.getStatusCode()); + Assertions.assertEquals( + List.of("Failed Validation: title : field is mandatory"), + response.getErrorMessages()); + } + @Test public void mapsInstanceNotFoundWithRouteContextToLegacyPathMessage() { ThingCommandResult result = diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/ThingCommandServiceTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/ThingCommandServiceTest.java index 9f0660a5..cbf23ce4 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/ThingCommandServiceTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/ThingCommandServiceTest.java @@ -259,10 +259,52 @@ public void createCommandValidatesDeclaredBodyTypesInApplication() { Assertions.assertTrue(result.isError()); Assertions.assertEquals( List.of("doneStatus should be BOOLEAN but was STRING"), result.getErrorMessages()); + Assertions.assertEquals(ApplicationError.Category.VALIDATION, result.getError().category()); + Assertions.assertEquals(ApplicationError.Code.VALIDATION_FAILED, result.getError().code()); Assertions.assertEquals( 0, store.entityQueries().count(thingifier.getDefinitionNamed("todo"))); } + @Test + public void createCommandMissingMandatoryFieldReturnsValidationCategory() { + Thingifier thingifier = mandatoryTitleThingifier(); + ThingStore store = storeFor(thingifier); + + ThingCommandResult result = + serviceFor(thingifier, store) + .execute(new CreateThingCommand("todo", List.of(), List.of(), true)); + + Assertions.assertTrue(result.isError()); + Assertions.assertEquals(ApplicationError.Category.VALIDATION, result.getError().category()); + Assertions.assertEquals(ApplicationError.Code.VALIDATION_FAILED, result.getError().code()); + Assertions.assertEquals( + List.of("Failed Validation: title : field is mandatory"), + result.getErrorMessages()); + } + + @Test + public void createCommandMaxInstanceLimitReturnsConflictCategory() { + Thingifier thingifier = limitedTodoThingifier(); + ThingStore store = storeFor(thingifier); + EntityDefinition todo = thingifier.getDefinitionNamed("todo"); + store.entities().create(EntityInstanceDraft.forEntity(todo).withField("title", "First")); + + ThingCommandResult result = + serviceFor(thingifier, store) + .execute( + new CreateThingCommand( + "todo", fields("title", "Second"), List.of(), true)); + + Assertions.assertTrue(result.isError()); + Assertions.assertEquals(ApplicationError.Category.CONFLICT, result.getError().category()); + Assertions.assertEquals( + ApplicationError.Code.MAX_INSTANCE_LIMIT_REACHED, result.getError().code()); + Assertions.assertEquals( + List.of("ERROR: Cannot add instance, maximum limit of 1 reached"), + result.getErrorMessages()); + Assertions.assertEquals(1, store.entityQueries().count(todo)); + } + @Test public void createCommandNormalizesNumericIntegerBodyValuesInApplication() { Thingifier thingifier = typedTodoThingifier(); @@ -755,6 +797,21 @@ private Thingifier typedTodoThingifier() { return thingifier; } + private Thingifier mandatoryTitleThingifier() { + Thingifier thingifier = new Thingifier(); + EntityDefinition todo = thingifier.defineThing("todo", "todos"); + todo.addField(Field.is("title", FieldType.STRING).makeMandatory()); + return thingifier; + } + + private Thingifier limitedTodoThingifier() { + Thingifier thingifier = new Thingifier(); + EntityDefinition todo = thingifier.defineThing("todo", "todos", 1); + todo.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT)); + todo.addField(Field.is("title", FieldType.STRING)); + return thingifier; + } + private Thingifier thingifierWithStringPrimaryKey() { Thingifier thingifier = new Thingifier(); EntityDefinition task = thingifier.defineThing("task", "tasks");