From bed860b21a497ea7e576975b66c5806664b69b2f Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Sat, 1 Aug 2026 11:36:39 +0100 Subject: [PATCH] Add configurable PUT identifier policies --- .../apihandlers/ThingWriteRequestMapper.java | 100 +++++++++++- .../http/apihandlers/WriteMethodPolicy.java | 132 ++++++++++++++-- .../api/docgen/WriteMethodRoutePolicy.java | 38 ++++- .../api/http/ThingifierRequestContext.java | 11 ++ .../restapihandlers/RestApiPutHandler.java | 4 +- .../api/spec/ThingifierApiSpec.java | 2 +- .../apiconfig/ApiConfigValidationReport.java | 91 +++++++++++ .../apiconfig/EntityWriteMethodConfig.java | 39 +++++ .../apiconfig/PutIdentifierPolicy.java | 7 + .../apiconfig/ThingifierApiConfig.java | 4 + .../apiconfig/WriteMethodsConfig.java | 6 + .../application/AmendThingHandler.java | 27 +++- .../EntityInstanceDraftBuilder.java | 11 +- .../ThingWriteRequestMapperTest.java | 97 ++++++++++++ .../WriteMethodPolicyTest.java | 149 ++++++++++++++++++ .../apiconfig/WriteMethodsConfigTest.java | 39 +++++ 16 files changed, 730 insertions(+), 27 deletions(-) create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/ApiConfigValidationReport.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/PutIdentifierPolicy.java diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ThingWriteRequestMapper.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ThingWriteRequestMapper.java index 3ea0963b..79a85616 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ThingWriteRequestMapper.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ThingWriteRequestMapper.java @@ -8,6 +8,8 @@ import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.ThingRoute; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.UnmatchedRoute; import uk.co.compendiumdev.thingifier.api.http.bodyparser.ApiBodyFields; +import uk.co.compendiumdev.thingifier.apiconfig.EntityWriteMethodConfig; +import uk.co.compendiumdev.thingifier.apiconfig.PutIdentifierPolicy; import uk.co.compendiumdev.thingifier.application.command.DeleteThingCommand; import uk.co.compendiumdev.thingifier.application.command.DisconnectRelationshipCommand; import uk.co.compendiumdev.thingifier.application.command.RelateThingCommand; @@ -19,10 +21,18 @@ public final class ThingWriteRequestMapper { private final SchemaViewCatalog schema; private final ThingBodyCommandMapper bodyCommandMapper; + private final EntityWriteMethodConfig entityWriteMethods; public ThingWriteRequestMapper(final SchemaViewCatalog schema) { + this(schema, new EntityWriteMethodConfig()); + } + + public ThingWriteRequestMapper( + final SchemaViewCatalog schema, final EntityWriteMethodConfig entityWriteMethods) { this.schema = schema; this.bodyCommandMapper = new ThingBodyCommandMapper(schema); + this.entityWriteMethods = + entityWriteMethods == null ? new EntityWriteMethodConfig() : entityWriteMethods; } public ThingWriteRequestMapping mapPost( @@ -55,13 +65,13 @@ public ThingWriteRequestMapping mapPost( public ThingWriteRequestMapping mapPut(final ThingRoute route, final ApiBodyFields bodyFields) { if (route instanceof CollectionRoute) { - return ThingWriteRequestMapping.error( - ApiMappingError.withMessage(405, "Cannot create root level entity with a PUT")); + CollectionRoute collection = (CollectionRoute) route; + return mapPutToEntity(bodyFields, collection.entity(), null); } if (route instanceof InstanceRoute) { InstanceRoute instance = (InstanceRoute) route; - return bodyCommandMapper.mapPut(bodyFields, instance.entity(), instance.identifier()); + return mapPutToEntity(bodyFields, instance.entity(), instance.identifier()); } if (route instanceof UnmatchedRoute) { @@ -75,6 +85,90 @@ public ThingWriteRequestMapping mapPut(final ThingRoute route, final ApiBodyFiel ApiMappingError.withMessage(400, "Your request was not understood")); } + private ThingWriteRequestMapping mapPutToEntity( + final ApiBodyFields bodyFields, + final EntityTypeRef entity, + final String uriIdentifier) { + ApiMappingError identityError = putIdentifierPolicyError(bodyFields, entity, uriIdentifier); + if (identityError != null) { + return ThingWriteRequestMapping.error(identityError); + } + + String identifier = + hasIdentifier(uriIdentifier) + ? uriIdentifier + : payloadIdentifier(bodyFields, entity); + if (!hasIdentifier(identifier)) { + if (!entity.hasPrimaryKeyField()) { + return ThingWriteRequestMapping.error(missingPrimaryKeyDefinitionError(entity)); + } + return ThingWriteRequestMapping.error( + ApiMappingError.withMessage( + 422, "PUT requires an identifier in the URI or payload")); + } + + return bodyCommandMapper.mapPut(bodyFields, entity, identifier); + } + + private ApiMappingError putIdentifierPolicyError( + final ApiBodyFields bodyFields, + final EntityTypeRef entity, + final String uriIdentifier) { + boolean hasUriIdentifier = hasIdentifier(uriIdentifier); + if (!hasUriIdentifier + && entityWriteMethods.putIdentifierInUri() == PutIdentifierPolicy.MANDATORY) { + return ApiMappingError.withMessage(405, "Cannot create root level entity with a PUT"); + } + if (hasUriIdentifier + && entityWriteMethods.putIdentifierInUri() == PutIdentifierPolicy.DISALLOWED) { + return ApiMappingError.withMessage(405, "Cannot identify entity with URI for PUT"); + } + + boolean hasPayloadIdentifier = hasPayloadIdentifier(bodyFields, entity); + if (entityWriteMethods.putIdentifierInPayload() == PutIdentifierPolicy.MANDATORY + && !hasPayloadIdentifier) { + if (!entity.hasPrimaryKeyField()) { + return missingPrimaryKeyDefinitionError(entity); + } + return ApiMappingError.withMessage( + 422, + String.format( + "PUT payload must include identifier field %s", + entity.primaryKeyFieldName())); + } + if (entityWriteMethods.putIdentifierInPayload() == PutIdentifierPolicy.DISALLOWED + && hasPayloadIdentifier) { + return ApiMappingError.withMessage( + 422, + String.format( + "PUT payload must not include identifier field %s", + entity.primaryKeyFieldName())); + } + return null; + } + + private ApiMappingError missingPrimaryKeyDefinitionError(final EntityTypeRef entity) { + return ApiMappingError.withMessage( + 404, String.format("Entity %s does not have a primary key defined", entity.name())); + } + + private boolean hasPayloadIdentifier( + final ApiBodyFields bodyFields, final EntityTypeRef entity) { + return entity.hasPrimaryKeyField() + && bodyFields.asStringMap().containsKey(entity.primaryKeyFieldName()); + } + + private String payloadIdentifier(final ApiBodyFields bodyFields, final EntityTypeRef entity) { + if (!entity.hasPrimaryKeyField()) { + return null; + } + return bodyFields.asStringMap().get(entity.primaryKeyFieldName()); + } + + private boolean hasIdentifier(final String identifier) { + return identifier != null && !identifier.trim().isEmpty(); + } + public ThingWriteRequestMapping mapPatch( final ThingRoute route, final ApiBodyFields bodyFields) { if (route instanceof CollectionRoute) { diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/WriteMethodPolicy.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/WriteMethodPolicy.java index 0286afb6..bf95718c 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/WriteMethodPolicy.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/WriteMethodPolicy.java @@ -12,14 +12,16 @@ import uk.co.compendiumdev.thingifier.api.http.ThingifierRequestContext; import uk.co.compendiumdev.thingifier.api.http.bodyparser.ApiBodyFields; import uk.co.compendiumdev.thingifier.api.response.ApiResponse; +import uk.co.compendiumdev.thingifier.apiconfig.ApiConfigValidationReport; import uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle; import uk.co.compendiumdev.thingifier.apiconfig.EntityWriteOperation; +import uk.co.compendiumdev.thingifier.apiconfig.PutIdentifierPolicy; import uk.co.compendiumdev.thingifier.apiconfig.RelationshipWriteOperation; +import uk.co.compendiumdev.thingifier.application.schema.EntityTypeRef; import uk.co.compendiumdev.thingifier.application.schema.RelationshipSpec; 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; public final class WriteMethodPolicy { @@ -34,8 +36,13 @@ public ApiResponse rejectIfNotAllowed( final ThingRoute route, final ApiBodyFields bodyFields, final ThingifierRequestContext context) { + ApiResponse invalidConfig = rejectInvalidApiConfig(); + if (invalidConfig != null) { + return invalidConfig; + } + if (route instanceof CollectionRoute || route instanceof InstanceRoute) { - return rejectEntityWriteIfNotAllowed(verb, route, context); + return rejectEntityWriteIfNotAllowed(verb, route, bodyFields, context); } if (route instanceof RelationshipCollectionRoute @@ -49,12 +56,23 @@ public ApiResponse rejectIfNotAllowed( private ApiResponse rejectEntityWriteIfNotAllowed( final RoutingVerb verb, final ThingRoute route, + final ApiBodyFields bodyFields, final ThingifierRequestContext context) { if (verb == RoutingVerb.PATCH) { - return rejectEntityPatchIfNotAllowed(route, context); + return rejectEntityPatchIfNotAllowed(route, bodyFields, context); + } + + if (verb == RoutingVerb.PUT && !canPutRouteUseIdentifier(route)) { + return methodNotAllowed( + allowHeaderFor( + route, + bodyFields, + context, + RoutingVerb.PUT, + EntityWriteOperation.CREATE)); } - EntityWriteOperation operation = entityOperationFor(verb, route, context); + EntityWriteOperation operation = entityOperationFor(verb, route, bodyFields, context); if (operation == null) { return null; } @@ -64,19 +82,31 @@ private ApiResponse rejectEntityWriteIfNotAllowed( return null; } - return methodNotAllowed(allowHeaderFor(route, context, verb, operation)); + return methodNotAllowed(allowHeaderFor(route, bodyFields, context, verb, operation)); } private ApiResponse rejectEntityPatchIfNotAllowed( - final ThingRoute route, final ThingifierRequestContext context) { + final ThingRoute route, + final ApiBodyFields bodyFields, + final ThingifierRequestContext context) { if (route instanceof CollectionRoute) { return methodNotAllowed( - allowHeaderFor(route, context, RoutingVerb.PATCH, EntityWriteOperation.UPDATE)); + allowHeaderFor( + route, + bodyFields, + context, + RoutingVerb.PATCH, + EntityWriteOperation.UPDATE)); } if (route instanceof InstanceRoute && entityPatchUpdateStylesFor(route).isEmpty()) { return methodNotAllowed( - allowHeaderFor(route, context, RoutingVerb.PATCH, EntityWriteOperation.UPDATE)); + allowHeaderFor( + route, + bodyFields, + context, + RoutingVerb.PATCH, + EntityWriteOperation.UPDATE)); } return null; @@ -101,9 +131,18 @@ private ApiResponse methodNotAllowed(final String allowHeader) { return ApiResponse.error(405, "Method Not Allowed").setHeader("Allow", allowHeader); } + private ApiResponse rejectInvalidApiConfig() { + ApiConfigValidationReport validation = runtime.apiConfig().validate(); + if (validation.isValid()) { + return null; + } + return ApiResponse.error(500, validation.errorMessages()); + } + private EntityWriteOperation entityOperationFor( final RoutingVerb verb, final ThingRoute route, + final ApiBodyFields bodyFields, final ThingifierRequestContext context) { if (verb == RoutingVerb.POST && route instanceof CollectionRoute) { return EntityWriteOperation.CREATE; @@ -112,8 +151,13 @@ private EntityWriteOperation entityOperationFor( && route instanceof InstanceRoute) { return EntityWriteOperation.UPDATE; } - if (verb == RoutingVerb.PUT && route instanceof InstanceRoute) { - return entityInstanceExists((InstanceRoute) route, context) + if (verb == RoutingVerb.PUT + && (route instanceof CollectionRoute || route instanceof InstanceRoute)) { + String identifier = putIdentifierFor(route, bodyFields); + if (!hasIdentifier(identifier)) { + return null; + } + return entityInstanceExists(entityFor(route), identifier, context) ? EntityWriteOperation.UPDATE : EntityWriteOperation.CREATE; } @@ -121,15 +165,55 @@ private EntityWriteOperation entityOperationFor( } private boolean entityInstanceExists( - final InstanceRoute route, final ThingifierRequestContext context) { + final EntityTypeRef entityRef, + final String identifier, + final ThingifierRequestContext context) { EntityDefinition entity = - runtime.schema().definitionWithSingularOrPluralNamed(route.entity().name()); + runtime.schema().definitionWithSingularOrPluralNamed(entityRef.name()); if (entity == null) { return false; } - EntityInstance found = - context.store().entityQueries().findByQueryIdentifier(entity, route.identifier()); - return found != null; + return context.hasEntityInstanceWithIdentifier(entity, identifier); + } + + private EntityTypeRef entityFor(final ThingRoute route) { + if (route instanceof CollectionRoute) { + return ((CollectionRoute) route).entity(); + } + return ((InstanceRoute) route).entity(); + } + + private String putIdentifierFor(final ThingRoute route, final ApiBodyFields bodyFields) { + if (route instanceof InstanceRoute) { + return ((InstanceRoute) route).identifier(); + } + if (route instanceof CollectionRoute) { + EntityTypeRef entity = ((CollectionRoute) route).entity(); + if (entity.hasPrimaryKeyField()) { + return bodyFields.asStringMap().get(entity.primaryKeyFieldName()); + } + } + return null; + } + + private boolean canPutRouteUseIdentifier(final ThingRoute route) { + if (route instanceof CollectionRoute) { + CollectionRoute collection = (CollectionRoute) route; + return collection.entity().hasPrimaryKeyField() + && runtime.apiConfig().writeMethods().entities().putIdentifierInUri() + != PutIdentifierPolicy.MANDATORY + && runtime.apiConfig().writeMethods().entities().putIdentifierInPayload() + != PutIdentifierPolicy.DISALLOWED; + } + if (route instanceof InstanceRoute) { + return runtime.apiConfig().writeMethods().entities().putIdentifierInUri() + != PutIdentifierPolicy.DISALLOWED; + } + return false; + } + + private boolean hasIdentifier(final String identifier) { + return identifier != null && !identifier.trim().isEmpty(); } private RelationshipWriteOperation relationshipOperationFor( @@ -199,6 +283,7 @@ private Set relationshipOperationsFor( private String allowHeaderFor( final ThingRoute route, + final ApiBodyFields bodyFields, final ThingifierRequestContext context, final RoutingVerb blockedVerb, final EntityWriteOperation blockedOperation) { @@ -211,6 +296,10 @@ private String allowHeaderFor( .contains(EntityWriteOperation.CREATE)) { allowed.add("POST"); } + if (isEntityMethodAllowedFor( + RoutingVerb.PUT, route, bodyFields, context, blockedVerb, blockedOperation)) { + allowed.add("PUT"); + } allowed.add("QUERY"); } if (route instanceof InstanceRoute) { @@ -221,7 +310,7 @@ private String allowHeaderFor( allowed.add("POST"); } if (isEntityMethodAllowedFor( - RoutingVerb.PUT, route, context, blockedVerb, blockedOperation)) { + RoutingVerb.PUT, route, bodyFields, context, blockedVerb, blockedOperation)) { allowed.add("PUT"); } if (!entityPatchUpdateStylesFor(route).isEmpty()) { @@ -235,11 +324,20 @@ private String allowHeaderFor( private boolean isEntityMethodAllowedFor( final RoutingVerb verb, final ThingRoute route, + final ApiBodyFields bodyFields, final ThingifierRequestContext context, final RoutingVerb blockedVerb, final EntityWriteOperation blockedOperation) { + if (verb == RoutingVerb.PUT && !canPutRouteUseIdentifier(route)) { + return false; + } EntityWriteOperation operation = - verb == blockedVerb ? blockedOperation : entityOperationFor(verb, route, context); + verb == blockedVerb + ? blockedOperation + : entityOperationFor(verb, route, bodyFields, context); + if (operation == null && verb == RoutingVerb.PUT && route instanceof CollectionRoute) { + return !entityOperationsFor(verb, route).isEmpty(); + } return operation != null && entityOperationsFor(verb, route).contains(operation); } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/WriteMethodRoutePolicy.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/WriteMethodRoutePolicy.java index e27a5628..f78c2c9a 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/WriteMethodRoutePolicy.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/WriteMethodRoutePolicy.java @@ -10,8 +10,10 @@ import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.RelationshipInstanceRoute; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.ThingRoute; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.ThingRouteMapper; +import uk.co.compendiumdev.thingifier.apiconfig.ApiConfigValidationReport; import uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle; import uk.co.compendiumdev.thingifier.apiconfig.EntityWriteOperation; +import uk.co.compendiumdev.thingifier.apiconfig.PutIdentifierPolicy; import uk.co.compendiumdev.thingifier.apiconfig.RelationshipWriteOperation; public final class WriteMethodRoutePolicy { @@ -25,6 +27,7 @@ public WriteMethodRoutePolicy(final Thingifier thingifier) { } public void applyTo(final ApiRoutingDefinition routingDefinition, final String apiPathPrefix) { + rejectInvalidApiConfig(); for (RoutingDefinition route : routingDefinition.definitions()) { ThingRoute thingRoute = new ThingRouteMapper(schema).map(removePrefix(route.url(), apiPathPrefix)); @@ -68,10 +71,20 @@ private void applyEntityPolicy( ((InstanceRoute) thingRoute).entity().name(), false); } + if (route.verb() == RoutingVerb.PUT && thingRoute instanceof CollectionRoute) { + Set operations = + entityOperationsFor(route.verb(), thingRoute, apiPathPrefix); + if (operations.isEmpty() || !canPutRouteUseIdentifier(thingRoute)) { + methodNotAllowed(route); + } else { + returnedEntityPutRoute( + route, ((CollectionRoute) thingRoute).entity().name(), operations); + } + } if (route.verb() == RoutingVerb.PUT && thingRoute instanceof InstanceRoute) { Set operations = entityOperationsFor(route.verb(), thingRoute, apiPathPrefix); - if (operations.isEmpty()) { + if (operations.isEmpty() || !canPutRouteUseIdentifier(thingRoute)) { methodNotAllowed(route); } else { returnedEntityPutRoute( @@ -199,6 +212,29 @@ private Set relationshipOperationsFor( .orElse(thingifier.apiConfig().writeMethods().relationships().operationsFor(verb)); } + private void rejectInvalidApiConfig() { + ApiConfigValidationReport validation = thingifier.apiConfig().validate(); + if (!validation.isValid()) { + throw new IllegalStateException(validation.combinedErrorMessages()); + } + } + + private boolean canPutRouteUseIdentifier(final ThingRoute route) { + if (route instanceof CollectionRoute) { + CollectionRoute collection = (CollectionRoute) route; + return collection.entity().hasPrimaryKeyField() + && thingifier.apiConfig().writeMethods().entities().putIdentifierInUri() + != PutIdentifierPolicy.MANDATORY + && thingifier.apiConfig().writeMethods().entities().putIdentifierInPayload() + != PutIdentifierPolicy.DISALLOWED; + } + if (route instanceof InstanceRoute) { + return thingifier.apiConfig().writeMethods().entities().putIdentifierInUri() + != PutIdentifierPolicy.DISALLOWED; + } + return false; + } + private void methodNotAllowed(final RoutingDefinition route) { route.replaceStatus(RoutingStatus.returnValue(405)); } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierRequestContext.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierRequestContext.java index 364e1a27..db87b741 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierRequestContext.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierRequestContext.java @@ -5,6 +5,8 @@ import uk.co.compendiumdev.thingifier.Thingifier; import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock; import uk.co.compendiumdev.thingifier.core.EntityRelModel; +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; public final class ThingifierRequestContext { @@ -46,6 +48,15 @@ public ThingStore store() { return store; } + public boolean hasEntityInstanceWithIdentifier( + final EntityDefinition entity, final String identifier) { + if (entity == null) { + return false; + } + EntityInstance found = store.entityQueries().findByQueryIdentifier(entity, identifier); + return found != null; + } + public HttpHeadersBlock headers() { return headers; } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPutHandler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPutHandler.java index 576bbdb2..ca09948a 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPutHandler.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPutHandler.java @@ -51,7 +51,9 @@ public ApiResponse handle( } ThingWriteRequestMapping mapping = - new ThingWriteRequestMapper(runtime.schema()).mapPut(route, bodyFields); + new ThingWriteRequestMapper( + runtime.schema(), runtime.apiConfig().writeMethods().entities()) + .mapPut(route, bodyFields); ThingCommandResultApiMapper apiMapper = new ThingCommandResultApiMapper(runtime.apiConfig()); if (mapping.isError()) { diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpec.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpec.java index 7b863a14..6f61f29e 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpec.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpec.java @@ -213,7 +213,7 @@ private void configureEntityWritePolicy( final EntityWriteOperation... operations) { final String collectionPath = "/" + normalize(entityPath); final String instancePath = collectionPath + "/{id}"; - if (verb == RoutingVerb.POST) { + if (verb == RoutingVerb.POST || verb == RoutingVerb.PUT) { entityWritePolicyRules.add( new EntityWritePolicyRule(verb, collectionPath, entityOperations(operations))); } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/ApiConfigValidationReport.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/ApiConfigValidationReport.java new file mode 100644 index 00000000..55f65f50 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/ApiConfigValidationReport.java @@ -0,0 +1,91 @@ +package uk.co.compendiumdev.thingifier.apiconfig; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class ApiConfigValidationReport { + + private final List errors; + private final List warnings; + + public ApiConfigValidationReport() { + errors = new ArrayList<>(); + warnings = new ArrayList<>(); + } + + public void addError(final String path, final String message) { + errors.add(new ApiConfigValidationMessage(path, message)); + } + + public void addWarning(final String path, final String message) { + warnings.add(new ApiConfigValidationMessage(path, message)); + } + + public boolean isValid() { + return errors.isEmpty(); + } + + public boolean hasWarnings() { + return !warnings.isEmpty(); + } + + public List errors() { + return Collections.unmodifiableList(errors); + } + + public List warnings() { + return Collections.unmodifiableList(warnings); + } + + public List errorMessages() { + return messagesFor(errors); + } + + public List warningMessages() { + return messagesFor(warnings); + } + + public String combinedErrorMessages() { + return String.join("; ", errorMessages()); + } + + public String combinedWarningMessages() { + return String.join("; ", warningMessages()); + } + + private List messagesFor(final List messages) { + final List output = new ArrayList<>(); + for (ApiConfigValidationMessage message : messages) { + output.add(message.toString()); + } + return output; + } + + public static final class ApiConfigValidationMessage { + + private final String path; + private final String message; + + private ApiConfigValidationMessage(final String path, final String message) { + this.path = path; + this.message = message; + } + + public String path() { + return path; + } + + public String message() { + return message; + } + + @Override + public String toString() { + if (path == null || path.trim().isEmpty()) { + return message; + } + return path + ": " + message; + } + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/EntityWriteMethodConfig.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/EntityWriteMethodConfig.java index 308c0902..0e6335aa 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/EntityWriteMethodConfig.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/EntityWriteMethodConfig.java @@ -10,11 +10,15 @@ public final class EntityWriteMethodConfig { private EnumSet postOperations; private EnumSet putOperations; private EnumSet patchUpdateStyles; + private PutIdentifierPolicy putIdentifierInUri; + private PutIdentifierPolicy putIdentifierInPayload; public EntityWriteMethodConfig() { postOperations = operations(EntityWriteOperation.CREATE, EntityWriteOperation.UPDATE); putOperations = operations(EntityWriteOperation.CREATE, EntityWriteOperation.UPDATE); patchUpdateStyles = patchStyles(); + putIdentifierInUri = PutIdentifierPolicy.MANDATORY; + putIdentifierInPayload = PutIdentifierPolicy.OPTIONAL; } public EntityWriteMethodConfig postCan(final EntityWriteOperation... operations) { @@ -27,6 +31,16 @@ public EntityWriteMethodConfig putCan(final EntityWriteOperation... operations) return this; } + public EntityWriteMethodConfig putIdentifierInUri(final PutIdentifierPolicy policy) { + putIdentifierInUri = policyOrDefault(policy, PutIdentifierPolicy.MANDATORY); + return this; + } + + public EntityWriteMethodConfig putIdentifierInPayload(final PutIdentifierPolicy policy) { + putIdentifierInPayload = policyOrDefault(policy, PutIdentifierPolicy.OPTIONAL); + return this; + } + public EntityWriteMethodConfig patchCan(final EntityPatchUpdateStyle... styles) { patchUpdateStyles = patchStyles(styles); return this; @@ -56,6 +70,14 @@ public Set patchUpdateStyles() { return immutablePatchStyleCopyOf(patchUpdateStyles); } + public PutIdentifierPolicy putIdentifierInUri() { + return putIdentifierInUri; + } + + public PutIdentifierPolicy putIdentifierInPayload() { + return putIdentifierInPayload; + } + public Set operationsFor(final RoutingVerb verb) { if (verb == RoutingVerb.POST) { return postOperations(); @@ -70,6 +92,18 @@ public void setFrom(final EntityWriteMethodConfig source) { postOperations = copyOf(source.postOperations); putOperations = copyOf(source.putOperations); patchUpdateStyles = patchStyleCopyOf(source.patchUpdateStyles); + putIdentifierInUri = source.putIdentifierInUri; + putIdentifierInPayload = source.putIdentifierInPayload; + } + + void addValidationMessages(final ApiConfigValidationReport report, final String path) { + if (!putOperations.isEmpty() + && putIdentifierInUri == PutIdentifierPolicy.DISALLOWED + && putIdentifierInPayload == PutIdentifierPolicy.DISALLOWED) { + report.addWarning( + path + ".put", + "PUT is enabled but identifiers are disallowed in both URI and payload"); + } } static EnumSet operations(final EntityWriteOperation... operations) { @@ -118,4 +152,9 @@ private EnumSet patchStyleCopyOf( } return EnumSet.copyOf(styles); } + + private PutIdentifierPolicy policyOrDefault( + final PutIdentifierPolicy policy, final PutIdentifierPolicy defaultPolicy) { + return policy == null ? defaultPolicy : policy; + } } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/PutIdentifierPolicy.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/PutIdentifierPolicy.java new file mode 100644 index 00000000..18145f9d --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/PutIdentifierPolicy.java @@ -0,0 +1,7 @@ +package uk.co.compendiumdev.thingifier.apiconfig; + +public enum PutIdentifierPolicy { + MANDATORY, + OPTIONAL, + DISALLOWED +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/ThingifierApiConfig.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/ThingifierApiConfig.java index c94193e4..34faffee 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/ThingifierApiConfig.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/ThingifierApiConfig.java @@ -155,6 +155,10 @@ public WriteMethodsConfig writeMethods() { return writeMethodsConfig; } + public ApiConfigValidationReport validate() { + return writeMethodsConfig.validate(); + } + public JsonOutputConfig jsonOutput() { return jsonOutputConfig; } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/WriteMethodsConfig.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/WriteMethodsConfig.java index de27b5da..bee535f5 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/WriteMethodsConfig.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/WriteMethodsConfig.java @@ -18,6 +18,12 @@ public RelationshipWriteMethodConfig relationships() { return relationshipWriteMethods; } + public ApiConfigValidationReport validate() { + ApiConfigValidationReport report = new ApiConfigValidationReport(); + entityWriteMethods.addValidationMessages(report, "writeMethods.entities"); + return report; + } + public void setFrom(final WriteMethodsConfig source) { entityWriteMethods.setFrom(source.entities()); relationshipWriteMethods.setFrom(source.relationships()); 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 8c69ec41..c3b6bf7d 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 @@ -1,5 +1,6 @@ package uk.co.compendiumdev.thingifier.application; +import java.util.ArrayList; import java.util.List; import uk.co.compendiumdev.thingifier.application.command.AmendThingCommand; import uk.co.compendiumdev.thingifier.application.command.RelationshipReference; @@ -85,8 +86,12 @@ ThingCommandResult handle(final ReplaceThingCommand command) { EntityInstance instance = definitions.resolveInstance(entity, command.getIdentifier()); if (instance != null) { try { + List replacementValues = + fieldValuesWithIdentifierIfMissing( + entity, command.getIdentifier(), fieldValues); EntityInstanceDraft draft = - new EntityInstanceDraftBuilder(instance).setFieldValuesFrom(fieldValues); + new EntityInstanceDraftBuilder(instance) + .setFieldValuesFrom(replacementValues); return amend(instance, draft, true, true, command.getRelationships()); } catch (ThingStoreWriteException e) { throw e; @@ -154,4 +159,24 @@ private ThingCommandResult amend( return ThingCommandResult.error(ApplicationExceptionMessages.messageFrom(e)); } } + + private List fieldValuesWithIdentifierIfMissing( + final EntityDefinition entity, + final String identifier, + final List fieldValues) { + if (entity == null || !entity.hasPrimaryKeyField()) { + return fieldValues; + } + + String primaryKeyFieldName = entity.getPrimaryKeyField().getName(); + for (NamedValue fieldValue : fieldValues) { + if (fieldValue.getName().equals(primaryKeyFieldName)) { + return fieldValues; + } + } + + List replacementValues = new ArrayList<>(fieldValues); + replacementValues.add(new NamedValue(primaryKeyFieldName, identifier)); + return replacementValues; + } } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/EntityInstanceDraftBuilder.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/EntityInstanceDraftBuilder.java index cee02260..83abf4e5 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/EntityInstanceDraftBuilder.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/EntityInstanceDraftBuilder.java @@ -76,9 +76,7 @@ private List findAnyGuidOrIdDifferences( for (NamedValue entry : fieldValues) { Field field = instance.getEntity().getField(entry.name); - if (field == null - || (field.getType() != FieldType.AUTO_INCREMENT - && field.getType() != FieldType.AUTO_GUID)) { + if (field == null || !isIdentityField(instance, field)) { continue; } @@ -103,4 +101,11 @@ private List findAnyGuidOrIdDifferences( return errorMessages; } + + private boolean isIdentityField(final EntityInstance instance, final Field field) { + Field primaryKey = instance.getEntity().getPrimaryKeyField(); + return field.getType() == FieldType.AUTO_INCREMENT + || field.getType() == FieldType.AUTO_GUID + || (primaryKey != null && primaryKey.getName().equals(field.getName())); + } } diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingWriteRequestMapperTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingWriteRequestMapperTest.java index 2daa089d..16209397 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingWriteRequestMapperTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingWriteRequestMapperTest.java @@ -1,5 +1,9 @@ package uk.co.compendiumdev.thingifier.api.restapihandlers; +import static uk.co.compendiumdev.thingifier.apiconfig.PutIdentifierPolicy.DISALLOWED; +import static uk.co.compendiumdev.thingifier.apiconfig.PutIdentifierPolicy.MANDATORY; +import static uk.co.compendiumdev.thingifier.apiconfig.PutIdentifierPolicy.OPTIONAL; + import java.util.HashMap; import java.util.List; import java.util.Map; @@ -12,6 +16,7 @@ import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.ThingRoute; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.ThingRouteMapper; import uk.co.compendiumdev.thingifier.api.http.bodyparser.ApiBodyFields; +import uk.co.compendiumdev.thingifier.apiconfig.EntityWriteMethodConfig; import uk.co.compendiumdev.thingifier.application.command.AmendThingCommand; import uk.co.compendiumdev.thingifier.application.command.CreateThingCommand; import uk.co.compendiumdev.thingifier.application.command.DeleteThingCommand; @@ -87,6 +92,93 @@ public void mapsPutInstanceToPutCommand() { Assertions.assertTrue(mapping.getCommand() instanceof ReplaceThingCommand); } + @Test + public void defaultPutMappingRejectsCollectionRoutes() { + Thingifier thingifier = stringKeyThingifier(); + + ThingWriteRequestMapping mapping = + mapperFor(thingifier) + .mapPut( + routeFor(thingifier, "notes"), + parserFor("title", "Missing identifier")); + + Assertions.assertTrue(mapping.isError()); + Assertions.assertEquals(405, mapping.getError().statusCode()); + Assertions.assertEquals( + List.of("Cannot create root level entity with a PUT"), + mapping.getError().messages()); + } + + @Test + public void mapsPutCollectionToReplaceCommandWhenPayloadIdentifierIsAllowed() { + Thingifier thingifier = stringKeyThingifier(); + EntityWriteMethodConfig config = new EntityWriteMethodConfig().putIdentifierInUri(OPTIONAL); + + ThingWriteRequestMapping mapping = + mapperFor(thingifier, config) + .mapPut( + routeFor(thingifier, "notes"), + parserFor(Map.of("key", "n-1", "title", "Created"))); + + Assertions.assertFalse(mapping.isError()); + ReplaceThingCommand command = (ReplaceThingCommand) mapping.getCommand(); + Assertions.assertEquals("n-1", command.getIdentifier()); + } + + @Test + public void putMappingRejectsInstanceRoutesWhenUriIdentifierIsDisallowed() { + Thingifier thingifier = stringKeyThingifier(); + EntityWriteMethodConfig config = + new EntityWriteMethodConfig().putIdentifierInUri(DISALLOWED); + + ThingWriteRequestMapping mapping = + mapperFor(thingifier, config) + .mapPut( + routeFor(thingifier, "notes/n-1"), + parserFor(Map.of("key", "n-1", "title", "Blocked"))); + + Assertions.assertTrue(mapping.isError()); + Assertions.assertEquals(405, mapping.getError().statusCode()); + Assertions.assertEquals( + List.of("Cannot identify entity with URI for PUT"), mapping.getError().messages()); + } + + @Test + public void putMappingRejectsMissingMandatoryPayloadIdentifier() { + Thingifier thingifier = stringKeyThingifier(); + EntityWriteMethodConfig config = + new EntityWriteMethodConfig().putIdentifierInPayload(MANDATORY); + + ThingWriteRequestMapping mapping = + mapperFor(thingifier, config) + .mapPut(routeFor(thingifier, "notes/n-1"), parserFor("title", "Blocked")); + + Assertions.assertTrue(mapping.isError()); + Assertions.assertEquals(422, mapping.getError().statusCode()); + Assertions.assertEquals( + List.of("PUT payload must include identifier field key"), + mapping.getError().messages()); + } + + @Test + public void putMappingRejectsDisallowedPayloadIdentifier() { + Thingifier thingifier = stringKeyThingifier(); + EntityWriteMethodConfig config = + new EntityWriteMethodConfig().putIdentifierInPayload(DISALLOWED); + + ThingWriteRequestMapping mapping = + mapperFor(thingifier, config) + .mapPut( + routeFor(thingifier, "notes/n-1"), + parserFor(Map.of("key", "n-1", "title", "Blocked"))); + + Assertions.assertTrue(mapping.isError()); + Assertions.assertEquals(422, mapping.getError().statusCode()); + Assertions.assertEquals( + List.of("PUT payload must not include identifier field key"), + mapping.getError().messages()); + } + @Test public void postInstanceMapsToUnresolvedAmendCommand() { Thingifier thingifier = taskProjectThingifier(); @@ -233,6 +325,11 @@ private ThingWriteRequestMapper mapperFor(final Thingifier thingifier) { return new ThingWriteRequestMapper(new ThingifierSchemaCatalog(thingifier)); } + private ThingWriteRequestMapper mapperFor( + final Thingifier thingifier, final EntityWriteMethodConfig config) { + return new ThingWriteRequestMapper(new ThingifierSchemaCatalog(thingifier), config); + } + private ThingStore storeFor(final Thingifier thingifier) { return thingifier.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); } diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/WriteMethodPolicyTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/WriteMethodPolicyTest.java index 54cf9360..52b2d3fa 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/WriteMethodPolicyTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/WriteMethodPolicyTest.java @@ -5,6 +5,9 @@ import static uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle.PARTIAL_JSON_UPDATE; import static uk.co.compendiumdev.thingifier.apiconfig.EntityWriteOperation.CREATE; import static uk.co.compendiumdev.thingifier.apiconfig.EntityWriteOperation.UPDATE; +import static uk.co.compendiumdev.thingifier.apiconfig.PutIdentifierPolicy.DISALLOWED; +import static uk.co.compendiumdev.thingifier.apiconfig.PutIdentifierPolicy.MANDATORY; +import static uk.co.compendiumdev.thingifier.apiconfig.PutIdentifierPolicy.OPTIONAL; import static uk.co.compendiumdev.thingifier.apiconfig.RelationshipWriteOperation.CONNECT_EXISTING; import static uk.co.compendiumdev.thingifier.apiconfig.RelationshipWriteOperation.CREATE_AND_CONNECT; @@ -92,6 +95,115 @@ public void putUsesExistingTargetStateToResolveCreateOrUpdate() { 201, put(createOnly, "notes/two", "{\"title\":\"Two\"}").getStatusCode()); } + @Test + public void defaultPutIdentifierPolicyRequiresUriIdentifier() { + Thingifier thingifier = stringIdNotes(); + + ApiResponse response = put(thingifier, "notes", noteJson("one", "One")); + + Assertions.assertEquals(405, response.getStatusCode()); + Assertions.assertEquals( + "OPTIONS, GET, HEAD, POST, QUERY", response.getHeaderValue("Allow")); + Assertions.assertEquals(0, noteCount(thingifier)); + } + + @Test + public void putCanUsePayloadIdentifierOnCollectionRoutes() { + Thingifier thingifier = stringIdNotes(); + thingifier.apiConfig().writeMethods().entities().putIdentifierInUri(OPTIONAL); + createNote(thingifier, "one", "One"); + + ApiResponse updated = put(thingifier, "notes", noteJson("one", "Changed")); + ApiResponse created = put(thingifier, "notes", noteJson("two", "Two")); + + Assertions.assertEquals(200, updated.getStatusCode()); + Assertions.assertEquals("Changed", currentTitle(thingifier, "one")); + Assertions.assertEquals(201, created.getStatusCode()); + Assertions.assertEquals("Two", currentTitle(thingifier, "two")); + } + + @Test + public void putRejectsUriIdentifierWhenDisallowed() { + Thingifier thingifier = stringIdNotes(); + thingifier.apiConfig().writeMethods().entities().putIdentifierInUri(DISALLOWED); + + ApiResponse response = put(thingifier, "notes/one", noteJson("one", "One")); + + Assertions.assertEquals(405, response.getStatusCode()); + Assertions.assertFalse(response.getHeaderValue("Allow").contains("PUT")); + Assertions.assertEquals(0, noteCount(thingifier)); + } + + @Test + public void putRejectsMissingMandatoryPayloadIdentifierEvenWithUriIdentifier() { + Thingifier thingifier = stringIdNotes(); + thingifier.apiConfig().writeMethods().entities().putIdentifierInPayload(MANDATORY); + createNote(thingifier, "one", "One"); + + ApiResponse response = put(thingifier, "notes/one", "{\"title\":\"Blocked\"}"); + + Assertions.assertEquals(422, response.getStatusCode()); + Assertions.assertTrue( + response.getErrorMessages() + .contains("PUT payload must include identifier field id")); + Assertions.assertEquals("One", currentTitle(thingifier, "one")); + } + + @Test + public void putRejectsDisallowedPayloadIdentifierButStillAllowsUriIdentifier() { + Thingifier thingifier = stringIdNotes(); + thingifier.apiConfig().writeMethods().entities().putIdentifierInPayload(DISALLOWED); + createNote(thingifier, "one", "One"); + + ApiResponse rejected = put(thingifier, "notes/one", noteJson("one", "Blocked")); + ApiResponse accepted = put(thingifier, "notes/one", "{\"title\":\"Changed\"}"); + + Assertions.assertEquals(422, rejected.getStatusCode()); + Assertions.assertTrue( + rejected.getErrorMessages() + .contains("PUT payload must not include identifier field id")); + Assertions.assertEquals(200, accepted.getStatusCode()); + Assertions.assertEquals("Changed", currentTitle(thingifier, "one")); + } + + @Test + public void putKeepsExistingMismatchResponseWhenUriAndPayloadIdentifiersDiffer() { + Thingifier thingifier = stringIdNotes(); + + ApiResponse response = put(thingifier, "notes/route-key", noteJson("body-key", "Blocked")); + + Assertions.assertEquals(422, response.getStatusCode()); + Assertions.assertTrue( + response.getErrorMessages() + .contains( + "Cannot create note with PUT as key does not match body value " + + "route-key != body-key")); + Assertions.assertEquals(0, noteCount(thingifier)); + } + + @Test + public void collectionPutRespectsCreateAndUpdateOperationPolicy() { + Thingifier updateOnly = stringIdNotes(); + updateOnly.apiConfig().writeMethods().entities().putIdentifierInUri(OPTIONAL); + updateOnly.apiConfig().writeMethods().entities().putCan(UPDATE); + createNote(updateOnly, "one", "One"); + + Assertions.assertEquals( + 200, put(updateOnly, "notes", noteJson("one", "Changed")).getStatusCode()); + Assertions.assertEquals( + 405, put(updateOnly, "notes", noteJson("two", "Two")).getStatusCode()); + + Thingifier createOnly = stringIdNotes(); + createOnly.apiConfig().writeMethods().entities().putIdentifierInUri(OPTIONAL); + createOnly.apiConfig().writeMethods().entities().putCan(CREATE); + createNote(createOnly, "one", "One"); + + Assertions.assertEquals( + 405, put(createOnly, "notes", noteJson("one", "Changed")).getStatusCode()); + Assertions.assertEquals( + 201, put(createOnly, "notes", noteJson("two", "Two")).getStatusCode()); + } + @Test public void patchCanBeEnabledForEntityInstanceUpdates() { Thingifier thingifier = stringIdNotes(); @@ -615,6 +727,43 @@ public void generatedDocsReflectPutCreateCapabilities() { .value()); } + @Test + public void generatedDocsReflectPutIdentifierLocationPolicy() { + Thingifier collectionPut = autoIdNotes(); + collectionPut.apiConfig().writeMethods().entities().putIdentifierInUri(OPTIONAL); + + ApiRoutingDefinition collectionPutDefinition = + new ApiRoutingDefinitionDocGenerator(collectionPut).generate(""); + + Assertions.assertTrue( + route(collectionPutDefinition, RoutingVerb.PUT, "notes") + .status() + .isReturnedFromCall()); + Assertions.assertEquals( + Set.of(201, 200, 404, 422, 409), + statusCodes(route(collectionPutDefinition, RoutingVerb.PUT, "notes"))); + Assertions.assertEquals( + "OPTIONS, GET, HEAD, POST, QUERY, PUT", + route(collectionPutDefinition, RoutingVerb.OPTIONS, "notes").headerValue()); + + Thingifier uriDisallowed = autoIdNotes(); + uriDisallowed.apiConfig().writeMethods().entities().putIdentifierInUri(DISALLOWED); + + ApiRoutingDefinition uriDisallowedDefinition = + new ApiRoutingDefinitionDocGenerator(uriDisallowed).generate(""); + + Assertions.assertTrue( + route(uriDisallowedDefinition, RoutingVerb.PUT, "notes") + .status() + .isReturnedFromCall()); + Assertions.assertEquals( + 405, route(uriDisallowedDefinition, RoutingVerb.PUT, "notes/:id").status().value()); + Assertions.assertFalse( + route(uriDisallowedDefinition, RoutingVerb.OPTIONS, "notes/:id") + .headerValue() + .contains("PUT")); + } + @Test public void httpApiAndDirectApiSharePolicyResponses() { Thingifier thingifier = stringIdNotes(); diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/apiconfig/WriteMethodsConfigTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/apiconfig/WriteMethodsConfigTest.java index 22245b63..12dc0333 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/apiconfig/WriteMethodsConfigTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/apiconfig/WriteMethodsConfigTest.java @@ -3,6 +3,9 @@ import static uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle.PARTIAL_JSON_UPDATE; import static uk.co.compendiumdev.thingifier.apiconfig.EntityWriteOperation.CREATE; import static uk.co.compendiumdev.thingifier.apiconfig.EntityWriteOperation.UPDATE; +import static uk.co.compendiumdev.thingifier.apiconfig.PutIdentifierPolicy.DISALLOWED; +import static uk.co.compendiumdev.thingifier.apiconfig.PutIdentifierPolicy.MANDATORY; +import static uk.co.compendiumdev.thingifier.apiconfig.PutIdentifierPolicy.OPTIONAL; import static uk.co.compendiumdev.thingifier.apiconfig.RelationshipWriteOperation.CONNECT_EXISTING; import static uk.co.compendiumdev.thingifier.apiconfig.RelationshipWriteOperation.CREATE_AND_CONNECT; import static uk.co.compendiumdev.thingifier.apiconfig.RelationshipWriteOperation.DISCONNECT; @@ -21,6 +24,8 @@ public void defaultsPreserveExistingWriteMethodBehavior() { Assertions.assertEquals(Set.of(CREATE, UPDATE), config.entities().postOperations()); Assertions.assertEquals(Set.of(CREATE, UPDATE), config.entities().putOperations()); Assertions.assertEquals(Set.of(), config.entities().patchUpdateStyles()); + Assertions.assertEquals(MANDATORY, config.entities().putIdentifierInUri()); + Assertions.assertEquals(OPTIONAL, config.entities().putIdentifierInPayload()); Assertions.assertEquals( Set.of(CREATE_AND_CONNECT, CONNECT_EXISTING), config.relationships().postOperations()); @@ -43,6 +48,8 @@ public void configCanBeCopiedBetweenProfiles() { ThingifierApiConfig source = new ThingifierApiConfig(""); source.writeMethods().entities().postCan(CREATE); source.writeMethods().entities().patchCan(PARTIAL_JSON_UPDATE); + source.writeMethods().entities().putIdentifierInUri(OPTIONAL); + source.writeMethods().entities().putIdentifierInPayload(MANDATORY); source.writeMethods().relationships().postCan(CONNECT_EXISTING); ThingifierApiConfig target = new ThingifierApiConfig(""); @@ -51,10 +58,42 @@ public void configCanBeCopiedBetweenProfiles() { Assertions.assertEquals(Set.of(CREATE), target.writeMethods().entities().postOperations()); Assertions.assertEquals( Set.of(PARTIAL_JSON_UPDATE), target.writeMethods().entities().patchUpdateStyles()); + Assertions.assertEquals(OPTIONAL, target.writeMethods().entities().putIdentifierInUri()); + Assertions.assertEquals( + MANDATORY, target.writeMethods().entities().putIdentifierInPayload()); Assertions.assertEquals( Set.of(CONNECT_EXISTING), target.writeMethods().relationships().postOperations()); } + @Test + public void validationWarnsWhenPutHasNoAllowedIdentifierLocation() { + ThingifierApiConfig config = new ThingifierApiConfig(""); + config.writeMethods().entities().putIdentifierInUri(DISALLOWED); + config.writeMethods().entities().putIdentifierInPayload(DISALLOWED); + + ApiConfigValidationReport report = config.validate(); + + Assertions.assertTrue(report.isValid()); + Assertions.assertTrue(report.hasWarnings()); + Assertions.assertEquals( + "writeMethods.entities.put: PUT is enabled but identifiers are disallowed " + + "in both URI and payload", + report.warningMessages().get(0)); + } + + @Test + public void validationDoesNotWarnWhenPutIsNotSupported() { + WriteMethodsConfig config = new WriteMethodsConfig(); + config.entities().putCan(); + config.entities().putIdentifierInUri(DISALLOWED); + config.entities().putIdentifierInPayload(DISALLOWED); + + ApiConfigValidationReport report = config.validate(); + + Assertions.assertTrue(report.isValid()); + Assertions.assertFalse(report.hasWarnings()); + } + @Test @SuppressWarnings("deprecation") public void clearerThingifierAliasesExposeSameConfigurationObjects() {