From 5f0e91264b75b24949ef1d7f9a01966c825f79b2 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Thu, 30 Jul 2026 10:37:21 +0100 Subject: [PATCH 1/2] Add configurable write method policies --- .../ConfiguredWriteMethodsThingifier.java | 33 ++ pom.xml | 1 + .../pom.xml | 81 ++++ .../application/Main.java | 34 ++ .../ConfiguredWriteMethodsSampleApiTest.java | 154 ++++++++ .../compendiumdev/thingifier/Thingifier.java | 19 +- .../DefaultThingifierApiRuntime.java | 6 + .../apihandlers/ThingWriteRequestMapper.java | 23 ++ .../apihandlers/ThingifierApiRuntime.java | 3 + .../http/apihandlers/WriteMethodPolicy.java | 255 ++++++++++++ .../api/ThingifierRestAPIHandler.java | 19 + .../ApiRoutingDefinitionDocGenerator.java | 1 + .../api/docgen/RoutingDefinition.java | 17 +- .../api/docgen/WriteMethodRoutePolicy.java | 221 +++++++++++ .../api/http/ApiRequestEnvelope.java | 4 +- .../api/http/ThingifierHttpApi.java | 9 +- .../restapihandlers/RestApiDeleteHandler.java | 16 +- .../restapihandlers/RestApiPatchHandler.java | 64 +++ .../restapihandlers/RestApiPostHandler.java | 14 +- .../restapihandlers/RestApiPutHandler.java | 14 +- .../api/spec/ThingifierApiRouteRule.java | 69 ++++ .../api/spec/ThingifierApiSpec.java | 182 +++++++++ .../apiconfig/EntityWriteMethodConfig.java | 100 +++++ .../apiconfig/EntityWriteOperation.java | 6 + .../RelationshipWriteMethodConfig.java | 125 ++++++ .../apiconfig/RelationshipWriteOperation.java | 7 + .../apiconfig/ThingifierApiConfig.java | 7 + .../apiconfig/WriteMethodsConfig.java | 25 ++ .../WriteMethodPolicyTest.java | 372 ++++++++++++++++++ .../apiconfig/WriteMethodsConfigTest.java | 64 +++ 30 files changed, 1934 insertions(+), 11 deletions(-) create mode 100644 examplemodels/src/main/java/uk/co/compendiumdev/thingifier/application/examples/ConfiguredWriteMethodsThingifier.java create mode 100644 standAloneConfiguredWriteMethodsRestApi/pom.xml create mode 100644 standAloneConfiguredWriteMethodsRestApi/src/main/java/uk/co/compendiumdev/configuredwritemethods/application/Main.java create mode 100644 standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsSampleApiTest.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/WriteMethodPolicy.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/WriteMethodRoutePolicy.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPatchHandler.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/EntityWriteMethodConfig.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/EntityWriteOperation.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/RelationshipWriteMethodConfig.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/RelationshipWriteOperation.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/WriteMethodsConfig.java create mode 100644 thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/WriteMethodPolicyTest.java create mode 100644 thingifier/src/test/java/uk/co/compendiumdev/thingifier/apiconfig/WriteMethodsConfigTest.java diff --git a/examplemodels/src/main/java/uk/co/compendiumdev/thingifier/application/examples/ConfiguredWriteMethodsThingifier.java b/examplemodels/src/main/java/uk/co/compendiumdev/thingifier/application/examples/ConfiguredWriteMethodsThingifier.java new file mode 100644 index 00000000..2a61b0d9 --- /dev/null +++ b/examplemodels/src/main/java/uk/co/compendiumdev/thingifier/application/examples/ConfiguredWriteMethodsThingifier.java @@ -0,0 +1,33 @@ +package uk.co.compendiumdev.thingifier.application.examples; + +import static uk.co.compendiumdev.thingifier.apiconfig.EntityWriteOperation.CREATE; +import static uk.co.compendiumdev.thingifier.apiconfig.EntityWriteOperation.UPDATE; +import static uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType.STRING; + +import uk.co.compendiumdev.thingifier.Thingifier; +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.definitions.validation.VRule; + +public class ConfiguredWriteMethodsThingifier { + + public Thingifier get() { + Thingifier notes = new Thingifier(); + notes.setDocumentation( + "Configured Write Methods", + "A sample API configured so POST creates, PATCH updates, and PUT updates."); + + EntityDefinition note = notes.defineThing("note", "notes"); + note.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT)); + note.addFields( + Field.is("title", STRING).makeMandatory().withValidation(VRule.notEmpty()), + Field.is("description", STRING)); + + notes.apiDefaults().writeMethods().entities().postCan(CREATE); + notes.apiDefaults().writeMethods().entities().patchCan(UPDATE); + notes.apiDefaults().writeMethods().entities().putCan(UPDATE); + + return notes; + } +} diff --git a/pom.xml b/pom.xml index 606a48cc..1396018b 100644 --- a/pom.xml +++ b/pom.xml @@ -140,6 +140,7 @@ thingifier-crud-ui todoManagerRestAuto standAloneTodoListRestApi + standAloneConfiguredWriteMethodsRestApi standAloneTodoListManagerRestApi standAloneSqlLiteTodoListManagerRestApi standAloneTodoListManagerRestApiAuto diff --git a/standAloneConfiguredWriteMethodsRestApi/pom.xml b/standAloneConfiguredWriteMethodsRestApi/pom.xml new file mode 100644 index 00000000..59c63a83 --- /dev/null +++ b/standAloneConfiguredWriteMethodsRestApi/pom.xml @@ -0,0 +1,81 @@ + + + 4.0.0 + + + uk.co.compendiumdev.thingifier + thingifier-root + 1.5.6-SNAPSHOT + + + standAloneConfiguredWriteMethodsRestApi + + + + uk.co.compendiumdev + thingifier + ${thingifier.version} + + + uk.co.compendiumdev.thingifier + examplemodels + ${thingifier.version} + + + org.junit.jupiter + junit-jupiter-api + ${junit.jupiter.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.jupiter.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.3 + + ${java.version} + ${java.version} + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M4 + + + maven-assembly-plugin + + + package + + single + + + + + + jar-with-dependencies + + runConfiguredWriteMethodsRestAPI-${project.version} + false + + + uk.co.compendiumdev.configuredwritemethods.application.Main + + + + + + + diff --git a/standAloneConfiguredWriteMethodsRestApi/src/main/java/uk/co/compendiumdev/configuredwritemethods/application/Main.java b/standAloneConfiguredWriteMethodsRestApi/src/main/java/uk/co/compendiumdev/configuredwritemethods/application/Main.java new file mode 100644 index 00000000..cf83a8bd --- /dev/null +++ b/standAloneConfiguredWriteMethodsRestApi/src/main/java/uk/co/compendiumdev/configuredwritemethods/application/Main.java @@ -0,0 +1,34 @@ +package uk.co.compendiumdev.configuredwritemethods.application; + +import static uk.co.compendiumdev.thingifier.adapter.httpserver.ServerRoutes.get; + +import uk.co.compendiumdev.thingifier.adapter.httpserver.MainImplementation; +import uk.co.compendiumdev.thingifier.application.examples.ConfiguredWriteMethodsThingifier; + +public class Main { + + public static void main(String[] args) { + MainImplementation app = new MainImplementation(); + app.registerModel("configuredWriteMethods", new ConfiguredWriteMethodsThingifier().get()); + + app.setDefaultsFromArgs(args); + + app.configurePortAndDefaultRoutes(); + app.setupBuiltInConfigurableRoutes(); + + app.chooseThingifier(); + app.configureThingifierWithProfile(); + + app.setupDefaultGui(); + + get( + "/", + (request, response) -> { + response.redirect("/gui/entities"); + return ""; + }); + + app.startRestServer(); + app.addBuiltInArgConfiguredHooks(); + } +} diff --git a/standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsSampleApiTest.java b/standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsSampleApiTest.java new file mode 100644 index 00000000..ffd2a328 --- /dev/null +++ b/standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsSampleApiTest.java @@ -0,0 +1,154 @@ +package uk.co.compendiumdev.configuredwritemethods.application; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.PathItem; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.api.docgen.ApiRoutingDefinition; +import uk.co.compendiumdev.thingifier.api.docgen.ApiRoutingDefinitionDocGenerator; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingDefinition; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; +import uk.co.compendiumdev.thingifier.api.docgen.ThingifierApiDocumentationDefn; +import uk.co.compendiumdev.thingifier.api.http.HttpApiRequest; +import uk.co.compendiumdev.thingifier.api.http.HttpApiResponse; +import uk.co.compendiumdev.thingifier.api.http.ThingifierHttpApi; +import uk.co.compendiumdev.thingifier.api.http.bodyparser.BodyParser; +import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock; +import uk.co.compendiumdev.thingifier.api.response.ApiResponse; +import uk.co.compendiumdev.thingifier.application.examples.ConfiguredWriteMethodsThingifier; +import uk.co.compendiumdev.thingifier.swaggerizer.Swaggerizer; + +class ConfiguredWriteMethodsSampleApiTest { + + @Test + void configuredMethodsAreEnforcedOverHttpApi() { + final Thingifier thingifier = sample(); + final ThingifierHttpApi httpApi = new ThingifierHttpApi(thingifier); + + final HttpApiResponse create = httpApi.post(jsonRequest("notes", "POST", noteJson("One"))); + final String id = create.apiResponse().getReturnedInstance().getPrimaryKeyValue(); + + Assertions.assertEquals(201, create.getStatusCode()); + Assertions.assertEquals( + 405, + httpApi.post(jsonRequest("notes/" + id, "POST", "{\"title\":\"Blocked\"}")) + .getStatusCode()); + Assertions.assertEquals( + 200, + httpApi.patch(jsonRequest("notes/" + id, "PATCH", "{\"title\":\"Patched\"}")) + .getStatusCode()); + Assertions.assertEquals( + 200, + httpApi.put(jsonRequest("notes/" + id, "PUT", "{\"title\":\"Put\"}")) + .getStatusCode()); + Assertions.assertEquals( + 405, + httpApi.put(jsonRequest("notes/999", "PUT", "{\"title\":\"Missing\"}")) + .getStatusCode()); + } + + @Test + void directApiReceivesTheSamePolicyResponses() { + final Thingifier thingifier = sample(); + final ApiResponse create = post(thingifier, "notes", noteJson("One")); + final String id = create.getReturnedInstance().getPrimaryKeyValue(); + + Assertions.assertEquals(201, create.getStatusCode()); + Assertions.assertEquals( + 405, post(thingifier, "notes/" + id, "{\"title\":\"Blocked\"}").getStatusCode()); + + final ApiResponse patch = + patch(thingifier, "notes/" + id, "{\"title\":\"Patched Direct\"}"); + Assertions.assertEquals(200, patch.getStatusCode()); + Assertions.assertEquals( + "Patched Direct", patch.getReturnedInstance().getFieldValue("title").asString()); + + final ApiResponse put = put(thingifier, "notes/" + id, "{\"title\":\"Put Direct\"}"); + Assertions.assertEquals(200, put.getStatusCode()); + Assertions.assertEquals( + "Put Direct", put.getReturnedInstance().getFieldValue("title").asString()); + Assertions.assertEquals( + 405, put(thingifier, "notes/999", "{\"title\":\"Missing\"}").getStatusCode()); + } + + @Test + void documentationAndSwaggerExposeOnlyAllowedWriteMethods() { + final Thingifier thingifier = sample(); + final ApiRoutingDefinition definition = + new ApiRoutingDefinitionDocGenerator(thingifier).generate(""); + + Assertions.assertTrue( + route(definition, RoutingVerb.POST, "notes").status().isReturnedFromCall()); + Assertions.assertEquals( + 405, route(definition, RoutingVerb.POST, "notes/:id").status().value()); + Assertions.assertEquals(405, route(definition, RoutingVerb.PUT, "notes").status().value()); + Assertions.assertEquals( + 405, route(definition, RoutingVerb.PATCH, "notes").status().value()); + Assertions.assertTrue( + route(definition, RoutingVerb.PATCH, "notes/:id").status().isReturnedFromCall()); + Assertions.assertTrue( + route(definition, RoutingVerb.PUT, "notes/:id").status().isReturnedFromCall()); + Assertions.assertEquals( + "OPTIONS, GET, HEAD, POST, QUERY", + route(definition, RoutingVerb.OPTIONS, "notes").headerValue()); + Assertions.assertEquals( + "OPTIONS, GET, HEAD, PUT, PATCH, DELETE", + route(definition, RoutingVerb.OPTIONS, "notes/:id").headerValue()); + + final OpenAPI openApi = + new Swaggerizer(new ThingifierApiDocumentationDefn().setThingifier(thingifier)) + .swagger(); + final PathItem collection = openApi.getPaths().get("/notes"); + final PathItem instance = openApi.getPaths().get("/notes/{id}"); + + Assertions.assertNotNull(collection.getPost()); + Assertions.assertNull(collection.getPut()); + Assertions.assertNull(collection.getPatch()); + Assertions.assertNull(instance.getPost()); + Assertions.assertNotNull(instance.getPatch()); + Assertions.assertNotNull(instance.getPut()); + } + + private Thingifier sample() { + return new ConfiguredWriteMethodsThingifier().get(); + } + + private ApiResponse post(final Thingifier thingifier, final String path, final String body) { + return thingifier.api().post(path, parser(thingifier, body), new HttpHeadersBlock()); + } + + private ApiResponse put(final Thingifier thingifier, final String path, final String body) { + return thingifier.api().put(path, parser(thingifier, body), new HttpHeadersBlock()); + } + + private ApiResponse patch(final Thingifier thingifier, final String path, final String body) { + return thingifier.api().patch(path, parser(thingifier, body), new HttpHeadersBlock()); + } + + private BodyParser parser(final Thingifier thingifier, final String body) { + return new BodyParser( + new HttpApiRequest("/request").setBody(body), thingifier.getThingNames()); + } + + private HttpApiRequest jsonRequest(final String path, final String verb, final String body) { + return new HttpApiRequest(path) + .setVerb(verb) + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "application/json") + .setBody(body); + } + + private String noteJson(final String title) { + return "{\"title\":\"" + title + "\",\"description\":\"sample\"}"; + } + + 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(); + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/Thingifier.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/Thingifier.java index a3a441f6..0dafa516 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/Thingifier.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/Thingifier.java @@ -208,18 +208,33 @@ public ThingifierRestAPIHandler api() { return new ThingifierRestAPIHandler(this); } - public ThingifierApiConfig apiConfig() { + public ThingifierApiConfig apiDefaults() { return apiConfig; } + /** + * Use {@link #apiDefaults()} for global API defaults. Route-specific API configuration lives + * behind {@link #apiContract()}. + */ + @Deprecated(since = "1.5.6", forRemoval = false) + public ThingifierApiConfig apiConfig() { + return apiDefaults(); + } + public ThingifierApiConfigProfiles apiConfigProfiles() { return apiConfigProfiles; } - public ThingifierApiSpec apiSpec() { + public ThingifierApiSpec apiContract() { return apiSpec; } + /** Use {@link #apiContract()} for route, entity, and relationship-level API configuration. */ + @Deprecated(since = "1.5.6", forRemoval = false) + public ThingifierApiSpec apiSpec() { + return apiContract(); + } + public ThingifierGuiConfig guiConfig() { return guiConfig; } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/DefaultThingifierApiRuntime.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/DefaultThingifierApiRuntime.java index 75106d4d..a2430d5c 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/DefaultThingifierApiRuntime.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/DefaultThingifierApiRuntime.java @@ -4,6 +4,7 @@ import uk.co.compendiumdev.thingifier.Thingifier; import uk.co.compendiumdev.thingifier.api.http.ThingifierRequestContext; import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock; +import uk.co.compendiumdev.thingifier.api.spec.ThingifierApiSpec; import uk.co.compendiumdev.thingifier.apiconfig.ThingifierApiConfig; import uk.co.compendiumdev.thingifier.application.ThingCommandService; import uk.co.compendiumdev.thingifier.application.ThingQueryService; @@ -30,6 +31,11 @@ public ThingifierApiConfig apiConfig() { return thingifier.apiConfig(); } + @Override + public ThingifierApiSpec apiSpec() { + return thingifier.apiSpec(); + } + @Override public List thingNames() { return thingifier.getThingNames(); 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 3c5ac7cb..d87351e5 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 @@ -75,6 +75,29 @@ public ThingWriteRequestMapping mapPut(final ThingRoute route, final ApiBodyFiel ApiMappingError.withMessage(400, "Your request was not understood")); } + public ThingWriteRequestMapping mapPatch( + final ThingRoute route, final ApiBodyFields bodyFields) { + if (route instanceof CollectionRoute) { + return ThingWriteRequestMapping.error( + ApiMappingError.withMessage(405, "Cannot patch root level entity")); + } + + if (route instanceof InstanceRoute) { + InstanceRoute instance = (InstanceRoute) route; + return mapPostToInstance(instance, bodyFields); + } + + if (route instanceof UnmatchedRoute) { + UnmatchedRoute unmatched = (UnmatchedRoute) route; + if (!unmatched.firstPart().isEmpty() && unmatched.partCount() == 2) { + return ThingWriteRequestMapping.error(NoSuchEntity.error(unmatched.firstPart())); + } + } + + return ThingWriteRequestMapping.error( + ApiMappingError.withMessage(400, "Your request was not understood")); + } + public ThingWriteRequestMapping mapDelete(final ThingRoute route) { if (route instanceof CollectionRoute) { return ThingWriteRequestMapping.error( diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ThingifierApiRuntime.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ThingifierApiRuntime.java index 366d4fae..155a0d16 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ThingifierApiRuntime.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ThingifierApiRuntime.java @@ -3,6 +3,7 @@ import java.util.List; import uk.co.compendiumdev.thingifier.api.http.ThingifierRequestContext; import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock; +import uk.co.compendiumdev.thingifier.api.spec.ThingifierApiSpec; import uk.co.compendiumdev.thingifier.apiconfig.ThingifierApiConfig; import uk.co.compendiumdev.thingifier.application.ThingCommandService; import uk.co.compendiumdev.thingifier.application.ThingQueryService; @@ -13,6 +14,8 @@ public interface ThingifierApiRuntime { ThingifierApiConfig apiConfig(); + ThingifierApiSpec apiSpec(); + List thingNames(); ThingifierRequestContext contextFrom(HttpHeadersBlock requestHeaders); 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 new file mode 100644 index 00000000..53dd5b2e --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/WriteMethodPolicy.java @@ -0,0 +1,255 @@ +package uk.co.compendiumdev.thingifier.adapter.http.apihandlers; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.CollectionRoute; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.InstanceRoute; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.RelationshipCollectionRoute; +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.api.docgen.RoutingVerb; +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.EntityWriteOperation; +import uk.co.compendiumdev.thingifier.apiconfig.RelationshipWriteOperation; +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 { + + private final ThingifierApiRuntime runtime; + + public WriteMethodPolicy(final ThingifierApiRuntime runtime) { + this.runtime = runtime; + } + + public ApiResponse rejectIfNotAllowed( + final RoutingVerb verb, + final ThingRoute route, + final ApiBodyFields bodyFields, + final ThingifierRequestContext context) { + if (route instanceof CollectionRoute || route instanceof InstanceRoute) { + return rejectEntityWriteIfNotAllowed(verb, route, context); + } + + if (route instanceof RelationshipCollectionRoute + || route instanceof RelationshipInstanceRoute) { + return rejectRelationshipWriteIfNotAllowed(verb, route, bodyFields); + } + + return null; + } + + private ApiResponse rejectEntityWriteIfNotAllowed( + final RoutingVerb verb, + final ThingRoute route, + final ThingifierRequestContext context) { + EntityWriteOperation operation = entityOperationFor(verb, route, context); + if (operation == null) { + return null; + } + + Set allowed = entityOperationsFor(verb, route); + if (allowed.contains(operation)) { + return null; + } + + return methodNotAllowed(allowHeaderFor(route, context, verb, operation)); + } + + private ApiResponse rejectRelationshipWriteIfNotAllowed( + final RoutingVerb verb, final ThingRoute route, final ApiBodyFields bodyFields) { + RelationshipWriteOperation operation = relationshipOperationFor(verb, route, bodyFields); + if (operation == null) { + return null; + } + + Set allowed = relationshipOperationsFor(verb, route); + if (allowed.contains(operation)) { + return null; + } + + return methodNotAllowed(allowHeaderFor(route, verb, operation)); + } + + private ApiResponse methodNotAllowed(final String allowHeader) { + return ApiResponse.error(405, "Method Not Allowed").setHeader("Allow", allowHeader); + } + + private EntityWriteOperation entityOperationFor( + final RoutingVerb verb, + final ThingRoute route, + final ThingifierRequestContext context) { + if (verb == RoutingVerb.POST && route instanceof CollectionRoute) { + return EntityWriteOperation.CREATE; + } + if ((verb == RoutingVerb.POST || verb == RoutingVerb.PATCH) + && route instanceof InstanceRoute) { + return EntityWriteOperation.UPDATE; + } + if (verb == RoutingVerb.PUT && route instanceof InstanceRoute) { + return entityInstanceExists((InstanceRoute) route, context) + ? EntityWriteOperation.UPDATE + : EntityWriteOperation.CREATE; + } + return null; + } + + private boolean entityInstanceExists( + final InstanceRoute route, final ThingifierRequestContext context) { + EntityDefinition entity = + runtime.schema().definitionWithSingularOrPluralNamed(route.entity().name()); + if (entity == null) { + return false; + } + EntityInstance found = + context.store().entityQueries().findByQueryIdentifier(entity, route.identifier()); + return found != null; + } + + private RelationshipWriteOperation relationshipOperationFor( + final RoutingVerb verb, final ThingRoute route, final ApiBodyFields bodyFields) { + if (verb == RoutingVerb.DELETE && route instanceof RelationshipInstanceRoute) { + return RelationshipWriteOperation.DISCONNECT; + } + if (verb == RoutingVerb.POST && route instanceof RelationshipCollectionRoute) { + return bodyReferencesExistingRelatedItem( + (RelationshipCollectionRoute) route, bodyFields) + ? RelationshipWriteOperation.CONNECT_EXISTING + : RelationshipWriteOperation.CREATE_AND_CONNECT; + } + return null; + } + + private boolean bodyReferencesExistingRelatedItem( + final RelationshipCollectionRoute route, final ApiBodyFields bodyFields) { + EntityDefinition targetEntity = targetEntityFor(route); + if (targetEntity == null) { + return false; + } + + for (java.util.Map.Entry entry : bodyFields.asFlattenedStringMap()) { + Field field = targetEntity.getField(entry.getKey()); + if (field != null + && (field.getType() == FieldType.AUTO_GUID + || field.getType() == FieldType.AUTO_INCREMENT)) { + return true; + } + } + return false; + } + + private EntityDefinition targetEntityFor(final RelationshipCollectionRoute route) { + for (RelationshipSpec relationship : route.parentEntity().relationships()) { + if (relationship.name().equals(route.relationshipName())) { + return runtime.schema() + .definitionWithSingularOrPluralNamed(relationship.toEntityName()); + } + } + return null; + } + + private Set entityOperationsFor( + final RoutingVerb verb, final ThingRoute route) { + return runtime.apiSpec() + .entityWriteOperationsFor( + verb, route.originalPath(), runtime.apiConfig().getApiEndPointPrefix()) + .orElse(runtime.apiConfig().writeMethods().entities().operationsFor(verb)); + } + + private Set relationshipOperationsFor( + final RoutingVerb verb, final ThingRoute route) { + return runtime.apiSpec() + .relationshipWriteOperationsFor( + verb, route.originalPath(), runtime.apiConfig().getApiEndPointPrefix()) + .orElse(runtime.apiConfig().writeMethods().relationships().operationsFor(verb)); + } + + private String allowHeaderFor( + final ThingRoute route, + final ThingifierRequestContext context, + final RoutingVerb blockedVerb, + final EntityWriteOperation blockedOperation) { + List allowed = new ArrayList<>(); + allowed.add("OPTIONS"); + if (route instanceof CollectionRoute) { + allowed.add("GET"); + allowed.add("HEAD"); + if (entityOperationsFor(RoutingVerb.POST, route) + .contains(EntityWriteOperation.CREATE)) { + allowed.add("POST"); + } + allowed.add("QUERY"); + } + if (route instanceof InstanceRoute) { + allowed.add("GET"); + allowed.add("HEAD"); + if (entityOperationsFor(RoutingVerb.POST, route) + .contains(EntityWriteOperation.UPDATE)) { + allowed.add("POST"); + } + if (isEntityMethodAllowedFor( + RoutingVerb.PUT, route, context, blockedVerb, blockedOperation)) { + allowed.add("PUT"); + } + if (entityOperationsFor(RoutingVerb.PATCH, route) + .contains(EntityWriteOperation.UPDATE)) { + allowed.add("PATCH"); + } + allowed.add("DELETE"); + } + return String.join(", ", allowed); + } + + private boolean isEntityMethodAllowedFor( + final RoutingVerb verb, + final ThingRoute route, + final ThingifierRequestContext context, + final RoutingVerb blockedVerb, + final EntityWriteOperation blockedOperation) { + EntityWriteOperation operation = + verb == blockedVerb ? blockedOperation : entityOperationFor(verb, route, context); + return operation != null && entityOperationsFor(verb, route).contains(operation); + } + + private String allowHeaderFor( + final ThingRoute route, + final RoutingVerb blockedVerb, + final RelationshipWriteOperation blockedOperation) { + List allowed = new ArrayList<>(); + allowed.add("OPTIONS"); + if (route instanceof RelationshipCollectionRoute) { + allowed.add("GET"); + allowed.add("HEAD"); + if (isRelationshipMethodAllowedFor( + RoutingVerb.POST, route, blockedVerb, blockedOperation)) { + allowed.add("POST"); + } + allowed.add("QUERY"); + } + if (route instanceof RelationshipInstanceRoute) { + if (isRelationshipMethodAllowedFor( + RoutingVerb.DELETE, route, blockedVerb, blockedOperation)) { + allowed.add("DELETE"); + } + } + return String.join(", ", allowed); + } + + private boolean isRelationshipMethodAllowedFor( + final RoutingVerb verb, + final ThingRoute route, + final RoutingVerb blockedVerb, + final RelationshipWriteOperation blockedOperation) { + RelationshipWriteOperation operation = + verb == blockedVerb + ? blockedOperation + : relationshipOperationFor(verb, route, ApiBodyFields.empty()); + return operation != null && relationshipOperationsFor(verb, route).contains(operation); + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ThingifierRestAPIHandler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ThingifierRestAPIHandler.java index d59a3c44..c787a600 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ThingifierRestAPIHandler.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ThingifierRestAPIHandler.java @@ -17,6 +17,7 @@ public class ThingifierRestAPIHandler { private final RestApiDeleteHandler delete; private final RestApiPostHandler post; private final RestApiPutHandler put; + private final RestApiPatchHandler patch; private final RestApiGetHandler get; private final RestApiQueryHandler query; @@ -30,6 +31,7 @@ public ThingifierRestAPIHandler(final ThingifierApiRuntime runtime) { this.delete = new RestApiDeleteHandler(runtime); this.post = new RestApiPostHandler(runtime); this.put = new RestApiPutHandler(runtime); + this.patch = new RestApiPatchHandler(runtime); this.query = new RestApiQueryHandler(runtime); } @@ -121,6 +123,23 @@ public ApiResponse put( return withRepository(put.handle(url, bodyFields, context), context); } + public ApiResponse patch(final String url, final BodyParser args, HttpHeadersBlock headers) { + ThingifierRequestContext context = contextFrom(headers); + return patch(url, args.bodyFields(), context); + } + + public ApiResponse patch(final ApiRequestEnvelope request) { + ThingifierRequestContext context = contextFrom(request.headers()); + return patch(request.path(), request.bodyFields(), context); + } + + public ApiResponse patch( + final String url, + final ApiBodyFields bodyFields, + final ThingifierRequestContext context) { + return withRepository(patch.handle(url, bodyFields, context), context); + } + private ThingifierRequestContext contextFrom(final HttpHeadersBlock headers) { return runtime.contextFrom(headers); } 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 37374a84..54b369c5 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 @@ -374,6 +374,7 @@ public ApiRoutingDefinition generate(String apiPathPrefix) { } } + new WriteMethodRoutePolicy(thingifier).applyTo(defn, apiPathPrefix); thingifier.apiSpec().applyTo(defn, apiPathPrefix); return defn; } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/RoutingDefinition.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/RoutingDefinition.java index 7740c903..d8abb9d3 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/RoutingDefinition.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/RoutingDefinition.java @@ -11,7 +11,7 @@ public class RoutingDefinition { private final RoutingVerb verb; private final String url; - private final RoutingStatus routingStatus; + private RoutingStatus routingStatus; private ResponseHeader header; private String documentation = ""; private boolean isFilterable; @@ -59,6 +59,11 @@ public RoutingStatus status() { return routingStatus; } + public RoutingDefinition replaceStatus(final RoutingStatus status) { + routingStatus = status; + return this; + } + public String url() { return url; } @@ -123,6 +128,11 @@ public RoutingDefinition addPossibleStatus(final RoutingStatus status) { return this; } + public RoutingDefinition clearPossibleStatuses() { + possibleStatusResponses.clear(); + return this; + } + public List getPossibleStatusReponses() { return possibleStatusResponses; } @@ -140,6 +150,11 @@ public RoutingDefinition returnPayload(final Integer statusCode, String objectSc return this; } + public RoutingDefinition clearReturnPayloads() { + returnPayload.clear(); + return this; + } + public boolean hasReturnPayloadFor(final Integer statusCode) { return returnPayload.containsKey(statusCode); } 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 new file mode 100644 index 00000000..8afd5ddf --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/WriteMethodRoutePolicy.java @@ -0,0 +1,221 @@ +package uk.co.compendiumdev.thingifier.api.docgen; + +import java.util.Set; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.SchemaCatalog; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.ThingifierSchemaCatalog; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.CollectionRoute; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.InstanceRoute; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.RelationshipCollectionRoute; +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.EntityWriteOperation; +import uk.co.compendiumdev.thingifier.apiconfig.RelationshipWriteOperation; + +public final class WriteMethodRoutePolicy { + + private final Thingifier thingifier; + private final SchemaCatalog schema; + + public WriteMethodRoutePolicy(final Thingifier thingifier) { + this.thingifier = thingifier; + this.schema = new ThingifierSchemaCatalog(thingifier); + } + + public void applyTo(final ApiRoutingDefinition routingDefinition, final String apiPathPrefix) { + for (RoutingDefinition route : routingDefinition.definitions()) { + ThingRoute thingRoute = + new ThingRouteMapper(schema).map(removePrefix(route.url(), apiPathPrefix)); + applyTo(route, thingRoute, apiPathPrefix); + } + } + + private void applyTo( + final RoutingDefinition route, + final ThingRoute thingRoute, + final String apiPathPrefix) { + if (thingRoute instanceof CollectionRoute || thingRoute instanceof InstanceRoute) { + applyEntityPolicy(route, thingRoute, apiPathPrefix); + } + if (thingRoute instanceof RelationshipCollectionRoute + || thingRoute instanceof RelationshipInstanceRoute) { + applyRelationshipPolicy(route, thingRoute, apiPathPrefix); + } + } + + private void applyEntityPolicy( + final RoutingDefinition route, + final ThingRoute thingRoute, + final String apiPathPrefix) { + if (route.verb() == RoutingVerb.POST && thingRoute instanceof CollectionRoute) { + setEntityRouteSupport( + route, + thingRoute, + apiPathPrefix, + EntityWriteOperation.CREATE, + ((CollectionRoute) thingRoute).entity().name(), + true); + } + if (route.verb() == RoutingVerb.POST && thingRoute instanceof InstanceRoute) { + setEntityRouteSupport( + route, + thingRoute, + apiPathPrefix, + EntityWriteOperation.UPDATE, + ((InstanceRoute) thingRoute).entity().name(), + false); + } + if (route.verb() == RoutingVerb.PUT && thingRoute instanceof InstanceRoute) { + Set operations = + entityOperationsFor(route.verb(), thingRoute, apiPathPrefix); + if (operations.isEmpty()) { + methodNotAllowed(route); + } else { + returnedEntityPutRoute( + route, ((InstanceRoute) thingRoute).entity().name(), operations); + } + } + if (route.verb() == RoutingVerb.PATCH && thingRoute instanceof InstanceRoute) { + setEntityRouteSupport( + route, + thingRoute, + apiPathPrefix, + EntityWriteOperation.UPDATE, + ((InstanceRoute) thingRoute).entity().name(), + false); + } + } + + private void setEntityRouteSupport( + final RoutingDefinition route, + final ThingRoute thingRoute, + final String apiPathPrefix, + final EntityWriteOperation operation, + final String entityName, + final boolean createRoute) { + if (entityOperationsFor(route.verb(), thingRoute, apiPathPrefix).contains(operation)) { + returnedEntityWriteRoute(route, entityName, createRoute); + } else { + methodNotAllowed(route); + } + } + + private void returnedEntityWriteRoute( + final RoutingDefinition route, final String entityName, final boolean createRoute) { + route.replaceStatus(RoutingStatus.returnedFromCall()); + if (createRoute) { + ensureStatus(route, 201); + route.returnPayload(201, entityName); + route.requestPayload("create_" + entityName); + return; + } + ensureStatus(route, 200); + ensureStatus(route, 404); + ensureStatus(route, 422); + ensureStatus(route, 409); + route.returnPayload(200, entityName); + route.requestPayload(entityName); + } + + private void returnedEntityPutRoute( + final RoutingDefinition route, + final String entityName, + final Set operations) { + route.replaceStatus(RoutingStatus.returnedFromCall()); + route.clearPossibleStatuses(); + route.clearReturnPayloads(); + if (operations.contains(EntityWriteOperation.CREATE)) { + ensureStatus(route, 201); + route.returnPayload(201, entityName); + } + if (operations.contains(EntityWriteOperation.UPDATE)) { + ensureStatus(route, 200); + ensureStatus(route, 404); + route.returnPayload(200, entityName); + } + ensureStatus(route, 422); + ensureStatus(route, 409); + route.requestPayload(entityName); + } + + private void applyRelationshipPolicy( + final RoutingDefinition route, + final ThingRoute thingRoute, + final String apiPathPrefix) { + if (route.verb() == RoutingVerb.POST && thingRoute instanceof RelationshipCollectionRoute) { + Set operations = + relationshipOperationsFor(route.verb(), thingRoute, apiPathPrefix); + if (operations.contains(RelationshipWriteOperation.CREATE_AND_CONNECT) + || operations.contains(RelationshipWriteOperation.CONNECT_EXISTING)) { + route.replaceStatus(RoutingStatus.returnedFromCall()); + } else { + methodNotAllowed(route); + } + } + if (route.verb() == RoutingVerb.DELETE && thingRoute instanceof RelationshipInstanceRoute) { + Set operations = + relationshipOperationsFor(route.verb(), thingRoute, apiPathPrefix); + if (operations.contains(RelationshipWriteOperation.DISCONNECT)) { + route.replaceStatus(RoutingStatus.returnedFromCall()); + } else { + methodNotAllowed(route); + } + } + } + + private Set entityOperationsFor( + final RoutingVerb verb, final ThingRoute route, final String apiPathPrefix) { + return thingifier + .apiSpec() + .entityWriteOperationsFor(verb, route.originalPath(), apiPathPrefix) + .orElse(thingifier.apiConfig().writeMethods().entities().operationsFor(verb)); + } + + private Set relationshipOperationsFor( + final RoutingVerb verb, final ThingRoute route, final String apiPathPrefix) { + return thingifier + .apiSpec() + .relationshipWriteOperationsFor(verb, route.originalPath(), apiPathPrefix) + .orElse(thingifier.apiConfig().writeMethods().relationships().operationsFor(verb)); + } + + private void methodNotAllowed(final RoutingDefinition route) { + route.replaceStatus(RoutingStatus.returnValue(405)); + } + + private void ensureStatus(final RoutingDefinition route, final int statusCode) { + for (RoutingStatus status : route.getPossibleStatusReponses()) { + if (status.value() == statusCode) { + return; + } + } + route.addPossibleStatus(RoutingStatus.returnValue(statusCode)); + } + + private String removePrefix(final String path, final String apiPathPrefix) { + final String normalizedPath = normalize(path); + final String normalizedPrefix = normalize(apiPathPrefix); + if (normalizedPrefix.isEmpty()) { + return normalizedPath; + } + if (normalizedPath.equals(normalizedPrefix)) { + return ""; + } + if (normalizedPath.startsWith(normalizedPrefix + "/")) { + return normalizedPath.substring(normalizedPrefix.length() + 1); + } + return normalizedPath; + } + + private String normalize(final String path) { + String normalized = path == null ? "" : path.trim(); + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ApiRequestEnvelope.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ApiRequestEnvelope.java index a94b6c1d..4150fe0c 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ApiRequestEnvelope.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ApiRequestEnvelope.java @@ -32,7 +32,9 @@ public static ApiRequestEnvelope from( final ThingifierHttpApi.HttpVerb verb, final List thingNames) { ApiBodyFields bodyFields = ApiBodyFields.empty(); - if (verb == ThingifierHttpApi.HttpVerb.POST || verb == ThingifierHttpApi.HttpVerb.PUT) { + if (verb == ThingifierHttpApi.HttpVerb.POST + || verb == ThingifierHttpApi.HttpVerb.PUT + || verb == ThingifierHttpApi.HttpVerb.PATCH) { bodyFields = new BodyParser(request, thingNames).bodyFields(); } QueryFilterParams queryParams = request.getFilterableQueryParams(); diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierHttpApi.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierHttpApi.java index c4535630..4af5a98a 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierHttpApi.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierHttpApi.java @@ -209,6 +209,9 @@ public ApiResponse routeAndProcessRequest(final HttpApiRequest request, HttpVerb case PUT: apiResponse = thingifier.api().put(envelope); break; + case PATCH: + apiResponse = thingifier.api().patch(envelope); + break; default: break; } @@ -219,7 +222,7 @@ public ApiResponse routeAndProcessRequest(final HttpApiRequest request, HttpVerb private HttpApiResponse validateEntityViewInput( final HttpApiRequest request, final HttpVerb verb) { - if (verb != HttpVerb.POST && verb != HttpVerb.PUT) { + if (verb != HttpVerb.POST && verb != HttpVerb.PUT && verb != HttpVerb.PATCH) { return null; } @@ -339,6 +342,10 @@ public HttpApiResponse put(final HttpApiRequest request) { return handleRequest(request, HttpVerb.PUT); } + public HttpApiResponse patch(final HttpApiRequest request) { + return handleRequest(request, HttpVerb.PATCH); + } + public HttpApiResponse queryRequest(final HttpApiRequest request) { return handleRequest(request, HttpVerb.QUERY); } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiDeleteHandler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiDeleteHandler.java index 4f8c10dd..ef661949 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiDeleteHandler.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiDeleteHandler.java @@ -6,8 +6,12 @@ import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.ThingWriteRequestMapper; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.ThingWriteRequestMapping; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.ThingifierApiRuntime; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.WriteMethodPolicy; +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.docgen.RoutingVerb; import uk.co.compendiumdev.thingifier.api.http.ThingifierRequestContext; +import uk.co.compendiumdev.thingifier.api.http.bodyparser.ApiBodyFields; import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock; import uk.co.compendiumdev.thingifier.api.response.ApiResponse; import uk.co.compendiumdev.thingifier.application.ThingCommandResult; @@ -28,9 +32,17 @@ public ApiResponse handle(final String url, HttpHeadersBlock requestHeaders) { } public ApiResponse handle(final String url, final ThingifierRequestContext context) { + ThingRoute route = new ThingRouteMapper(runtime.schema()).map(url); + ApiResponse policyResponse = + new WriteMethodPolicy(runtime) + .rejectIfNotAllowed( + RoutingVerb.DELETE, route, ApiBodyFields.empty(), context); + if (policyResponse != null) { + return policyResponse; + } + ThingWriteRequestMapping mapping = - new ThingWriteRequestMapper(runtime.schema()) - .mapDelete(new ThingRouteMapper(runtime.schema()).map(url)); + new ThingWriteRequestMapper(runtime.schema()).mapDelete(route); ThingCommandResultApiMapper apiMapper = new ThingCommandResultApiMapper(runtime.apiConfig()); if (mapping.isError()) { diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPatchHandler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPatchHandler.java new file mode 100644 index 00000000..2787cea7 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPatchHandler.java @@ -0,0 +1,64 @@ +package uk.co.compendiumdev.thingifier.api.restapihandlers; + +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.DefaultThingifierApiRuntime; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.ThingCommandResultApiMapper; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.ThingWriteRequestMapper; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.ThingWriteRequestMapping; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.ThingifierApiRuntime; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.WriteMethodPolicy; +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.docgen.RoutingVerb; +import uk.co.compendiumdev.thingifier.api.http.ThingifierRequestContext; +import uk.co.compendiumdev.thingifier.api.http.bodyparser.ApiBodyFields; +import uk.co.compendiumdev.thingifier.api.http.bodyparser.BodyParser; +import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock; +import uk.co.compendiumdev.thingifier.api.response.ApiResponse; +import uk.co.compendiumdev.thingifier.application.ThingCommandResult; + +public class RestApiPatchHandler { + private final ThingifierApiRuntime runtime; + + public RestApiPatchHandler(final Thingifier aThingifier) { + this(new DefaultThingifierApiRuntime(aThingifier)); + } + + public RestApiPatchHandler(final ThingifierApiRuntime runtime) { + this.runtime = runtime; + } + + public ApiResponse handle( + final String url, final BodyParser args, final HttpHeadersBlock requestHeaders) { + return handle(url, args.bodyFields(), runtime.contextFrom(requestHeaders)); + } + + public ApiResponse handle( + final String url, final BodyParser args, final ThingifierRequestContext context) { + return handle(url, args.bodyFields(), context); + } + + public ApiResponse handle( + final String url, + final ApiBodyFields bodyFields, + final ThingifierRequestContext context) { + ThingRoute route = new ThingRouteMapper(runtime.schema()).map(url); + ApiResponse policyResponse = + new WriteMethodPolicy(runtime) + .rejectIfNotAllowed(RoutingVerb.PATCH, route, bodyFields, context); + if (policyResponse != null) { + return policyResponse; + } + + ThingWriteRequestMapping mapping = + new ThingWriteRequestMapper(runtime.schema()).mapPatch(route, bodyFields); + ThingCommandResultApiMapper apiMapper = + new ThingCommandResultApiMapper(runtime.apiConfig()); + if (mapping.isError()) { + return apiMapper.map(mapping.getError()); + } + + ThingCommandResult result = runtime.commandService(context).execute(mapping.getCommand()); + return apiMapper.map(mapping, result); + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPostHandler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPostHandler.java index 1ed1b372..56519fdf 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPostHandler.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPostHandler.java @@ -6,7 +6,10 @@ import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.ThingWriteRequestMapper; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.ThingWriteRequestMapping; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.ThingifierApiRuntime; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.WriteMethodPolicy; +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.docgen.RoutingVerb; import uk.co.compendiumdev.thingifier.api.http.ThingifierRequestContext; import uk.co.compendiumdev.thingifier.api.http.bodyparser.ApiBodyFields; import uk.co.compendiumdev.thingifier.api.http.bodyparser.BodyParser; @@ -39,9 +42,16 @@ public ApiResponse handle( final String url, final ApiBodyFields bodyFields, final ThingifierRequestContext context) { + ThingRoute route = new ThingRouteMapper(runtime.schema()).map(url); + ApiResponse policyResponse = + new WriteMethodPolicy(runtime) + .rejectIfNotAllowed(RoutingVerb.POST, route, bodyFields, context); + if (policyResponse != null) { + return policyResponse; + } + ThingWriteRequestMapping mapping = - new ThingWriteRequestMapper(runtime.schema()) - .mapPost(new ThingRouteMapper(runtime.schema()).map(url), bodyFields); + new ThingWriteRequestMapper(runtime.schema()).mapPost(route, bodyFields); ThingCommandResultApiMapper apiMapper = new ThingCommandResultApiMapper(runtime.apiConfig()); if (mapping.isError()) { 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 d393327a..576bbdb2 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 @@ -6,7 +6,10 @@ import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.ThingWriteRequestMapper; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.ThingWriteRequestMapping; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.ThingifierApiRuntime; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.WriteMethodPolicy; +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.docgen.RoutingVerb; import uk.co.compendiumdev.thingifier.api.http.ThingifierRequestContext; import uk.co.compendiumdev.thingifier.api.http.bodyparser.ApiBodyFields; import uk.co.compendiumdev.thingifier.api.http.bodyparser.BodyParser; @@ -39,9 +42,16 @@ public ApiResponse handle( final String url, final ApiBodyFields bodyFields, final ThingifierRequestContext context) { + ThingRoute route = new ThingRouteMapper(runtime.schema()).map(url); + ApiResponse policyResponse = + new WriteMethodPolicy(runtime) + .rejectIfNotAllowed(RoutingVerb.PUT, route, bodyFields, context); + if (policyResponse != null) { + return policyResponse; + } + ThingWriteRequestMapping mapping = - new ThingWriteRequestMapper(runtime.schema()) - .mapPut(new ThingRouteMapper(runtime.schema()).map(url), bodyFields); + new ThingWriteRequestMapper(runtime.schema()).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/ThingifierApiRouteRule.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiRouteRule.java index b2de2a27..d466664a 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiRouteRule.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiRouteRule.java @@ -1,9 +1,14 @@ package uk.co.compendiumdev.thingifier.api.spec; +import java.util.Collections; +import java.util.EnumSet; import java.util.HashMap; import java.util.Map; +import java.util.Set; import uk.co.compendiumdev.thingifier.api.docgen.RoutingDefinition; import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; +import uk.co.compendiumdev.thingifier.apiconfig.EntityWriteOperation; +import uk.co.compendiumdev.thingifier.apiconfig.RelationshipWriteOperation; public final class ThingifierApiRouteRule { @@ -18,6 +23,8 @@ public final class ThingifierApiRouteRule { private String requestEntityView; private String defaultEntityView; private Map responseEntityViews; + private EnumSet entityWriteOperations; + private EnumSet relationshipWriteOperations; ThingifierApiRouteRule(final RoutingVerb verb, final String pathPattern) { this.verb = verb; @@ -31,6 +38,8 @@ public final class ThingifierApiRouteRule { this.requestEntityView = null; this.defaultEntityView = null; this.responseEntityViews = new HashMap<>(); + this.entityWriteOperations = null; + this.relationshipWriteOperations = null; } public RoutingVerb verb() { @@ -93,6 +102,25 @@ public ThingifierApiRouteRule responseEntityView(final int statusCode, final Str return this; } + public ThingifierApiRouteRule entityWriteOperations(final EntityWriteOperation... operations) { + this.entityWriteOperations = entityOperations(operations); + return this; + } + + public ThingifierApiRouteRule entityCan(final EntityWriteOperation... operations) { + return entityWriteOperations(operations); + } + + public ThingifierApiRouteRule relationshipWriteOperations( + final RelationshipWriteOperation... operations) { + this.relationshipWriteOperations = relationshipOperations(operations); + return this; + } + + public ThingifierApiRouteRule relationshipCan(final RelationshipWriteOperation... operations) { + return relationshipWriteOperations(operations); + } + public ThingifierApiRouteRule entityView(final String viewName) { this.requestEntityView = viewName; this.defaultEntityView = viewName; @@ -117,6 +145,28 @@ public String responseEntityViewFor(final int statusCode) { return null; } + public boolean hasEntityWriteOperations() { + return entityWriteOperations != null; + } + + public Set entityWriteOperations() { + if (entityWriteOperations == null || entityWriteOperations.isEmpty()) { + return Set.of(); + } + return Collections.unmodifiableSet(EnumSet.copyOf(entityWriteOperations)); + } + + public boolean hasRelationshipWriteOperations() { + return relationshipWriteOperations != null; + } + + public Set relationshipWriteOperations() { + if (relationshipWriteOperations == null || relationshipWriteOperations.isEmpty()) { + return Set.of(); + } + return Collections.unmodifiableSet(EnumSet.copyOf(relationshipWriteOperations)); + } + void applyTo(final RoutingDefinition route) { if (hidden) { route.hideFromDocumentation(); @@ -157,4 +207,23 @@ void applyTo(final RoutingDefinition route) { } } } + + private EnumSet entityOperations( + final EntityWriteOperation... operations) { + EnumSet selected = EnumSet.noneOf(EntityWriteOperation.class); + if (operations != null) { + Collections.addAll(selected, operations); + } + return selected; + } + + private EnumSet relationshipOperations( + final RelationshipWriteOperation... operations) { + EnumSet selected = + EnumSet.noneOf(RelationshipWriteOperation.class); + if (operations != null) { + Collections.addAll(selected, operations); + } + return selected; + } } 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 4bd8fd0f..377ab761 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 @@ -2,18 +2,27 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; import java.util.List; import java.util.Optional; +import java.util.Set; import uk.co.compendiumdev.thingifier.api.docgen.ApiRoutingDefinition; import uk.co.compendiumdev.thingifier.api.docgen.RoutingDefinition; import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; +import uk.co.compendiumdev.thingifier.apiconfig.EntityWriteOperation; +import uk.co.compendiumdev.thingifier.apiconfig.RelationshipWriteOperation; public final class ThingifierApiSpec { private final List routeRules; + private final List entityWritePolicyRules; + private final List relationshipWritePolicyRules; public ThingifierApiSpec() { routeRules = new ArrayList<>(); + entityWritePolicyRules = new ArrayList<>(); + relationshipWritePolicyRules = new ArrayList<>(); } public ThingifierApiRouteRule route(final RoutingVerb verb, final String pathPattern) { @@ -48,6 +57,42 @@ public ThingifierApiSpec disableRelationshipRoutes( return this; } + public ThingifierApiSpec entityPostCan( + final String entityPath, final EntityWriteOperation... operations) { + configureEntityWritePolicy(RoutingVerb.POST, entityPath, operations); + return this; + } + + public ThingifierApiSpec entityPutCan( + final String entityPath, final EntityWriteOperation... operations) { + configureEntityWritePolicy(RoutingVerb.PUT, entityPath, operations); + return this; + } + + public ThingifierApiSpec entityPatchCan( + final String entityPath, final EntityWriteOperation... operations) { + configureEntityWritePolicy(RoutingVerb.PATCH, entityPath, operations); + return this; + } + + public ThingifierApiSpec relationshipPostCan( + final String parentEntityPath, + final String relationshipName, + final RelationshipWriteOperation... operations) { + configureRelationshipWritePolicy( + RoutingVerb.POST, parentEntityPath, relationshipName, operations); + return this; + } + + public ThingifierApiSpec relationshipDeleteCan( + final String parentEntityPath, + final String relationshipName, + final RelationshipWriteOperation... operations) { + configureRelationshipWritePolicy( + RoutingVerb.DELETE, parentEntityPath, relationshipName, operations); + return this; + } + public void applyTo(final ApiRoutingDefinition routingDefinition, final String apiPathPrefix) { for (RoutingDefinition route : routingDefinition.definitions()) { ruleFor(route.verb(), route.url(), apiPathPrefix) @@ -82,6 +127,38 @@ public Optional ruleFor( .findFirst(); } + public Optional> entityWriteOperationsFor( + final RoutingVerb verb, final String path, final String apiPathPrefix) { + Optional routeRule = + ruleFor(verb, path, apiPathPrefix) + .filter(ThingifierApiRouteRule::hasEntityWriteOperations); + if (routeRule.isPresent()) { + return Optional.of(routeRule.get().entityWriteOperations()); + } + + return entityWritePolicyRules.stream() + .filter(rule -> rule.verb() == verb) + .filter(rule -> pathsMatch(rule.pathPattern(), path, apiPathPrefix)) + .map(EntityWritePolicyRule::operations) + .findFirst(); + } + + public Optional> relationshipWriteOperationsFor( + final RoutingVerb verb, final String path, final String apiPathPrefix) { + Optional routeRule = + ruleFor(verb, path, apiPathPrefix) + .filter(ThingifierApiRouteRule::hasRelationshipWriteOperations); + if (routeRule.isPresent()) { + return Optional.of(routeRule.get().relationshipWriteOperations()); + } + + return relationshipWritePolicyRules.stream() + .filter(rule -> rule.verb() == verb) + .filter(rule -> pathsMatch(rule.pathPattern(), path, apiPathPrefix)) + .map(RelationshipWritePolicyRule::operations) + .findFirst(); + } + private void configureEntityRoutes(final String entityPath, final boolean disable) { final String collectionPath = "/" + normalize(entityPath); final String instancePath = collectionPath + "/{id}"; @@ -112,6 +189,57 @@ private void configureRoute( } } + private void configureEntityWritePolicy( + final RoutingVerb verb, + final String entityPath, + final EntityWriteOperation... operations) { + final String collectionPath = "/" + normalize(entityPath); + final String instancePath = collectionPath + "/{id}"; + if (verb == RoutingVerb.POST) { + entityWritePolicyRules.add( + new EntityWritePolicyRule(verb, collectionPath, entityOperations(operations))); + } + entityWritePolicyRules.add( + new EntityWritePolicyRule(verb, instancePath, entityOperations(operations))); + } + + private void configureRelationshipWritePolicy( + final RoutingVerb verb, + final String parentEntityPath, + final String relationshipName, + final RelationshipWriteOperation... operations) { + final String relationshipPath = + "/" + normalize(parentEntityPath) + "/{id}/" + normalize(relationshipName); + final String path = + verb == RoutingVerb.DELETE ? relationshipPath + "/{relatedId}" : relationshipPath; + relationshipWritePolicyRules.add( + new RelationshipWritePolicyRule(verb, path, relationshipOperations(operations))); + } + + private Set entityOperations(final EntityWriteOperation... operations) { + EnumSet selected = EnumSet.noneOf(EntityWriteOperation.class); + if (operations != null) { + Collections.addAll(selected, operations); + } + if (selected.isEmpty()) { + return Set.of(); + } + return Collections.unmodifiableSet(EnumSet.copyOf(selected)); + } + + private Set relationshipOperations( + final RelationshipWriteOperation... operations) { + EnumSet selected = + EnumSet.noneOf(RelationshipWriteOperation.class); + if (operations != null) { + Collections.addAll(selected, operations); + } + if (selected.isEmpty()) { + return Set.of(); + } + return Collections.unmodifiableSet(EnumSet.copyOf(selected)); + } + private boolean pathsMatch( final String rulePath, final String candidatePath, final String apiPathPrefix) { final List ruleSegments = segments(rulePath, apiPathPrefix); @@ -177,4 +305,58 @@ private String normalizeParameterSegment(final String segment) { private boolean isWildcard(final String segment) { return "*".equals(segment); } + + private static final class EntityWritePolicyRule { + private final RoutingVerb verb; + private final String pathPattern; + private final Set operations; + + EntityWritePolicyRule( + final RoutingVerb verb, + final String pathPattern, + final Set operations) { + this.verb = verb; + this.pathPattern = pathPattern; + this.operations = operations; + } + + RoutingVerb verb() { + return verb; + } + + String pathPattern() { + return pathPattern; + } + + Set operations() { + return operations; + } + } + + private static final class RelationshipWritePolicyRule { + private final RoutingVerb verb; + private final String pathPattern; + private final Set operations; + + RelationshipWritePolicyRule( + final RoutingVerb verb, + final String pathPattern, + final Set operations) { + this.verb = verb; + this.pathPattern = pathPattern; + this.operations = operations; + } + + RoutingVerb verb() { + return verb; + } + + String pathPattern() { + return pathPattern; + } + + Set operations() { + return operations; + } + } } 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 new file mode 100644 index 00000000..b55af206 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/EntityWriteMethodConfig.java @@ -0,0 +1,100 @@ +package uk.co.compendiumdev.thingifier.apiconfig; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.Set; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; + +public final class EntityWriteMethodConfig { + + private EnumSet postOperations; + private EnumSet putOperations; + private EnumSet patchOperations; + + public EntityWriteMethodConfig() { + postOperations = operations(EntityWriteOperation.CREATE, EntityWriteOperation.UPDATE); + putOperations = operations(EntityWriteOperation.CREATE, EntityWriteOperation.UPDATE); + patchOperations = operations(); + } + + public EntityWriteMethodConfig postCan(final EntityWriteOperation... operations) { + postOperations = operations(operations); + return this; + } + + public EntityWriteMethodConfig putCan(final EntityWriteOperation... operations) { + putOperations = operations(operations); + return this; + } + + public EntityWriteMethodConfig patchCan(final EntityWriteOperation... operations) { + patchOperations = operations(operations); + return this; + } + + public EntityWriteMethodConfig postNotSupported() { + return postCan(); + } + + public EntityWriteMethodConfig putNotSupported() { + return putCan(); + } + + public EntityWriteMethodConfig patchNotSupported() { + return patchCan(); + } + + public Set postOperations() { + return immutableCopyOf(postOperations); + } + + public Set putOperations() { + return immutableCopyOf(putOperations); + } + + public Set patchOperations() { + return immutableCopyOf(patchOperations); + } + + public Set operationsFor(final RoutingVerb verb) { + if (verb == RoutingVerb.POST) { + return postOperations(); + } + if (verb == RoutingVerb.PUT) { + return putOperations(); + } + if (verb == RoutingVerb.PATCH) { + return patchOperations(); + } + return Set.of(); + } + + public void setFrom(final EntityWriteMethodConfig source) { + postOperations = copyOf(source.postOperations); + putOperations = copyOf(source.putOperations); + patchOperations = copyOf(source.patchOperations); + } + + static EnumSet operations(final EntityWriteOperation... operations) { + EnumSet selected = EnumSet.noneOf(EntityWriteOperation.class); + if (operations != null) { + Collections.addAll(selected, operations); + } + return selected; + } + + private Set immutableCopyOf( + final EnumSet operations) { + if (operations.isEmpty()) { + return Set.of(); + } + return Collections.unmodifiableSet(EnumSet.copyOf(operations)); + } + + private EnumSet copyOf(final EnumSet operations) { + if (operations.isEmpty()) { + return operations(); + } + return EnumSet.copyOf(operations); + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/EntityWriteOperation.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/EntityWriteOperation.java new file mode 100644 index 00000000..e37f87f5 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/EntityWriteOperation.java @@ -0,0 +1,6 @@ +package uk.co.compendiumdev.thingifier.apiconfig; + +public enum EntityWriteOperation { + CREATE, + UPDATE +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/RelationshipWriteMethodConfig.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/RelationshipWriteMethodConfig.java new file mode 100644 index 00000000..abe98d0c --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/RelationshipWriteMethodConfig.java @@ -0,0 +1,125 @@ +package uk.co.compendiumdev.thingifier.apiconfig; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.Set; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; + +public final class RelationshipWriteMethodConfig { + + private EnumSet postOperations; + private EnumSet deleteOperations; + private EnumSet putOperations; + private EnumSet patchOperations; + + public RelationshipWriteMethodConfig() { + postOperations = + operations( + RelationshipWriteOperation.CREATE_AND_CONNECT, + RelationshipWriteOperation.CONNECT_EXISTING); + deleteOperations = operations(RelationshipWriteOperation.DISCONNECT); + putOperations = operations(); + patchOperations = operations(); + } + + public RelationshipWriteMethodConfig postCan(final RelationshipWriteOperation... operations) { + postOperations = operations(operations); + return this; + } + + public RelationshipWriteMethodConfig deleteCan(final RelationshipWriteOperation... operations) { + deleteOperations = operations(operations); + return this; + } + + public RelationshipWriteMethodConfig putCan(final RelationshipWriteOperation... operations) { + putOperations = operations(operations); + return this; + } + + public RelationshipWriteMethodConfig patchCan(final RelationshipWriteOperation... operations) { + patchOperations = operations(operations); + return this; + } + + public RelationshipWriteMethodConfig postNotSupported() { + return postCan(); + } + + public RelationshipWriteMethodConfig deleteNotSupported() { + return deleteCan(); + } + + public RelationshipWriteMethodConfig putNotSupported() { + return putCan(); + } + + public RelationshipWriteMethodConfig patchNotSupported() { + return patchCan(); + } + + public Set postOperations() { + return immutableCopyOf(postOperations); + } + + public Set deleteOperations() { + return immutableCopyOf(deleteOperations); + } + + public Set putOperations() { + return immutableCopyOf(putOperations); + } + + public Set patchOperations() { + return immutableCopyOf(patchOperations); + } + + public Set operationsFor(final RoutingVerb verb) { + if (verb == RoutingVerb.POST) { + return postOperations(); + } + if (verb == RoutingVerb.DELETE) { + return deleteOperations(); + } + if (verb == RoutingVerb.PUT) { + return putOperations(); + } + if (verb == RoutingVerb.PATCH) { + return patchOperations(); + } + return Set.of(); + } + + public void setFrom(final RelationshipWriteMethodConfig source) { + postOperations = copyOf(source.postOperations); + deleteOperations = copyOf(source.deleteOperations); + putOperations = copyOf(source.putOperations); + patchOperations = copyOf(source.patchOperations); + } + + static EnumSet operations( + final RelationshipWriteOperation... operations) { + EnumSet selected = + EnumSet.noneOf(RelationshipWriteOperation.class); + if (operations != null) { + Collections.addAll(selected, operations); + } + return selected; + } + + private Set immutableCopyOf( + final EnumSet operations) { + if (operations.isEmpty()) { + return Set.of(); + } + return Collections.unmodifiableSet(EnumSet.copyOf(operations)); + } + + private EnumSet copyOf( + final EnumSet operations) { + if (operations.isEmpty()) { + return operations(); + } + return EnumSet.copyOf(operations); + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/RelationshipWriteOperation.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/RelationshipWriteOperation.java new file mode 100644 index 00000000..0066ad40 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/RelationshipWriteOperation.java @@ -0,0 +1,7 @@ +package uk.co.compendiumdev.thingifier.apiconfig; + +public enum RelationshipWriteOperation { + CREATE_AND_CONNECT, + CONNECT_EXISTING, + DISCONNECT +} 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 e51fdc18..c94193e4 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 @@ -55,6 +55,7 @@ public class ThingifierApiConfig { private boolean willShowPrimaryKeyHeaderInResponses; private final AdminConfig adminConfig; + private final WriteMethodsConfig writeMethodsConfig; // Requests // willEnforceDeclaredTypesInInput e.g. only accept if boolean is given as `true` indead of @@ -120,6 +121,7 @@ public ThingifierApiConfig(String endPointPrefix) { statusCodeConfig = new StatusCodeConfig(); adminConfig = new AdminConfig(); + writeMethodsConfig = new WriteMethodsConfig(); } public void setFrom(final ThingifierApiConfig apiConfig) { @@ -142,12 +144,17 @@ public void setFrom(final ThingifierApiConfig apiConfig) { statusCodeConfig.setFrom(apiConfig.statusCodes()); jsonOutputConfig.setFrom(apiConfig.jsonOutput()); adminConfig.setFrom(apiConfig.adminConfig()); + writeMethodsConfig.setFrom(apiConfig.writeMethods()); } public AdminConfig adminConfig() { return adminConfig; } + public WriteMethodsConfig writeMethods() { + return writeMethodsConfig; + } + 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 new file mode 100644 index 00000000..de27b5da --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/WriteMethodsConfig.java @@ -0,0 +1,25 @@ +package uk.co.compendiumdev.thingifier.apiconfig; + +public final class WriteMethodsConfig { + + private final EntityWriteMethodConfig entityWriteMethods; + private final RelationshipWriteMethodConfig relationshipWriteMethods; + + public WriteMethodsConfig() { + entityWriteMethods = new EntityWriteMethodConfig(); + relationshipWriteMethods = new RelationshipWriteMethodConfig(); + } + + public EntityWriteMethodConfig entities() { + return entityWriteMethods; + } + + public RelationshipWriteMethodConfig relationships() { + return relationshipWriteMethods; + } + + public void setFrom(final WriteMethodsConfig source) { + entityWriteMethods.setFrom(source.entities()); + relationshipWriteMethods.setFrom(source.relationships()); + } +} 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 new file mode 100644 index 00000000..0f920b2c --- /dev/null +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/WriteMethodPolicyTest.java @@ -0,0 +1,372 @@ +package uk.co.compendiumdev.thingifier.api.restapihandlers; + +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.RelationshipWriteOperation.CONNECT_EXISTING; +import static uk.co.compendiumdev.thingifier.apiconfig.RelationshipWriteOperation.CREATE_AND_CONNECT; + +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.api.docgen.ApiRoutingDefinition; +import uk.co.compendiumdev.thingifier.api.docgen.ApiRoutingDefinitionDocGenerator; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingDefinition; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; +import uk.co.compendiumdev.thingifier.api.http.HttpApiRequest; +import uk.co.compendiumdev.thingifier.api.http.ThingifierHttpApi; +import uk.co.compendiumdev.thingifier.api.http.bodyparser.BodyParser; +import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock; +import uk.co.compendiumdev.thingifier.api.response.ApiResponse; +import uk.co.compendiumdev.thingifier.core.EntityRelModel; +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; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; + +public class WriteMethodPolicyTest { + + @Test + public void defaultsKeepPostAndPutCreateAndUpdateWithPatchUnsupported() { + Thingifier thingifier = stringIdNotes(); + + Assertions.assertEquals( + 201, post(thingifier, "notes", noteJson("one", "One")).getStatusCode()); + Assertions.assertEquals( + 200, post(thingifier, "notes/one", "{\"title\":\"Changed\"}").getStatusCode()); + Assertions.assertEquals( + 201, put(thingifier, "notes/two", "{\"title\":\"Two\"}").getStatusCode()); + Assertions.assertEquals( + 405, patch(thingifier, "notes/one", "{\"title\":\"Patch\"}").getStatusCode()); + } + + @Test + public void postCanBeLimitedToCreateOnlyOrUpdateOnlyOrUnsupported() { + Thingifier createOnly = stringIdNotes(); + createOnly.apiConfig().writeMethods().entities().postCan(CREATE); + post(createOnly, "notes", noteJson("one", "One")); + Assertions.assertEquals( + 405, post(createOnly, "notes/one", "{\"title\":\"Blocked\"}").getStatusCode()); + + Thingifier updateOnly = stringIdNotes(); + updateOnly.apiConfig().writeMethods().entities().postCan(UPDATE); + EntityInstance note = createNote(updateOnly, "one", "One"); + Assertions.assertEquals( + 405, post(updateOnly, "notes", noteJson("two", "Two")).getStatusCode()); + Assertions.assertEquals( + 200, + post(updateOnly, "notes/" + note.getPrimaryKeyValue(), "{\"title\":\"Changed\"}") + .getStatusCode()); + + Thingifier unsupported = stringIdNotes(); + unsupported.apiConfig().writeMethods().entities().postCan(); + Assertions.assertEquals( + 405, post(unsupported, "notes", noteJson("one", "One")).getStatusCode()); + } + + @Test + public void putUsesExistingTargetStateToResolveCreateOrUpdate() { + Thingifier updateOnly = stringIdNotes(); + updateOnly.apiConfig().writeMethods().entities().putCan(UPDATE); + createNote(updateOnly, "one", "One"); + + Assertions.assertEquals( + 200, put(updateOnly, "notes/one", "{\"title\":\"Changed\"}").getStatusCode()); + Assertions.assertEquals( + 405, put(updateOnly, "notes/two", "{\"title\":\"Two\"}").getStatusCode()); + + Thingifier createOnly = stringIdNotes(); + createNote(createOnly, "one", "One"); + createOnly.apiConfig().writeMethods().entities().putCan(CREATE); + + Assertions.assertEquals( + 405, put(createOnly, "notes/one", "{\"title\":\"Changed\"}").getStatusCode()); + Assertions.assertEquals( + 201, put(createOnly, "notes/two", "{\"title\":\"Two\"}").getStatusCode()); + } + + @Test + public void patchCanBeEnabledForEntityInstanceUpdates() { + Thingifier thingifier = stringIdNotes(); + thingifier.apiConfig().writeMethods().entities().patchCan(UPDATE); + createNote(thingifier, "one", "One"); + + ApiResponse response = patch(thingifier, "notes/one", "{\"title\":\"Patched\"}"); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals( + "Patched", response.getReturnedInstance().getFieldValue("title").asString()); + } + + @Test + public void routeOverrideWinsOverEntityOverrideWhichWinsOverGlobalConfig() { + Thingifier thingifier = stringIdNotes(); + thingifier.apiConfig().writeMethods().entities().postCan(CREATE); + thingifier.apiSpec().entityPostCan("/notes", UPDATE); + thingifier.apiSpec().route(RoutingVerb.POST, "/notes/{id}").entityCan(); + createNote(thingifier, "one", "One"); + + Assertions.assertEquals( + 405, post(thingifier, "notes/one", "{\"title\":\"Blocked\"}").getStatusCode()); + + Thingifier entityOverride = stringIdNotes(); + entityOverride.apiConfig().writeMethods().entities().postCan(CREATE); + entityOverride.apiSpec().entityPostCan("/notes", UPDATE); + createNote(entityOverride, "one", "One"); + + Assertions.assertEquals( + 200, post(entityOverride, "notes/one", "{\"title\":\"Allowed\"}").getStatusCode()); + } + + @Test + public void relationshipPostCanBeLimitedByOperation() { + Thingifier createOnly = relationshipModel(); + createOnly.apiConfig().writeMethods().relationships().postCan(CREATE_AND_CONNECT); + EntityInstance project = createProject(createOnly, "Project"); + EntityInstance task = createTask(createOnly, "Existing"); + + Assertions.assertEquals( + 201, + post( + createOnly, + "projects/" + project.getPrimaryKeyValue() + "/tasks", + "{\"title\":\"New\"}") + .getStatusCode()); + Assertions.assertEquals( + 405, + post( + createOnly, + "projects/" + project.getPrimaryKeyValue() + "/tasks", + "{\"id\":" + task.getPrimaryKeyValue() + "}") + .getStatusCode()); + + Thingifier connectOnly = relationshipModel(); + connectOnly.apiConfig().writeMethods().relationships().postCan(CONNECT_EXISTING); + EntityInstance otherProject = createProject(connectOnly, "Project"); + EntityInstance otherTask = createTask(connectOnly, "Existing"); + + Assertions.assertEquals( + 405, + post( + connectOnly, + "projects/" + otherProject.getPrimaryKeyValue() + "/tasks", + "{\"title\":\"New\"}") + .getStatusCode()); + Assertions.assertEquals( + 201, + post( + connectOnly, + "projects/" + otherProject.getPrimaryKeyValue() + "/tasks", + "{\"id\":" + otherTask.getPrimaryKeyValue() + "}") + .getStatusCode()); + } + + @Test + public void relationshipDeleteCanDisableDisconnect() { + Thingifier thingifier = relationshipModel(); + thingifier.apiConfig().writeMethods().relationships().deleteCan(); + EntityInstance project = createProject(thingifier, "Project"); + EntityInstance task = createTask(thingifier, "Task"); + post( + thingifier, + "projects/" + project.getPrimaryKeyValue() + "/tasks", + "{\"id\":" + task.getPrimaryKeyValue() + "}"); + + ApiResponse response = + thingifier + .api() + .delete( + "projects/" + + project.getPrimaryKeyValue() + + "/tasks/" + + task.getPrimaryKeyValue(), + new HttpHeadersBlock()); + + Assertions.assertEquals(405, response.getStatusCode()); + } + + @Test + public void generatedDocsReflectConfiguredEntityPolicy() { + Thingifier thingifier = autoIdNotes(); + thingifier.apiConfig().writeMethods().entities().postCan(CREATE); + thingifier.apiConfig().writeMethods().entities().patchCan(UPDATE); + thingifier.apiConfig().writeMethods().entities().putCan(UPDATE); + + ApiRoutingDefinition definition = + new ApiRoutingDefinitionDocGenerator(thingifier).generate(""); + + Assertions.assertTrue( + route(definition, RoutingVerb.POST, "notes").status().isReturnedFromCall()); + Assertions.assertEquals( + 405, route(definition, RoutingVerb.POST, "notes/:id").status().value()); + Assertions.assertTrue( + route(definition, RoutingVerb.PATCH, "notes/:id").status().isReturnedFromCall()); + Assertions.assertTrue( + route(definition, RoutingVerb.PUT, "notes/:id").status().isReturnedFromCall()); + Assertions.assertEquals( + Set.of(200, 404, 422, 409), + statusCodes(route(definition, RoutingVerb.PUT, "notes/:id"))); + Assertions.assertEquals( + "OPTIONS, GET, HEAD, POST, QUERY", + route(definition, RoutingVerb.OPTIONS, "notes").headerValue()); + Assertions.assertEquals( + "OPTIONS, GET, HEAD, PUT, PATCH, DELETE", + route(definition, RoutingVerb.OPTIONS, "notes/:id").headerValue()); + } + + @Test + public void generatedDocsReflectPutCreateCapabilities() { + Thingifier createOnly = autoIdNotes(); + createOnly.apiConfig().writeMethods().entities().putCan(CREATE); + + Thingifier createAndUpdate = autoIdNotes(); + createAndUpdate.apiConfig().writeMethods().entities().putCan(CREATE, UPDATE); + + Thingifier unsupported = autoIdNotes(); + unsupported.apiConfig().writeMethods().entities().putCan(); + + Assertions.assertEquals( + Set.of(201, 422, 409), + statusCodes( + route( + new ApiRoutingDefinitionDocGenerator(createOnly).generate(""), + RoutingVerb.PUT, + "notes/:id"))); + Assertions.assertEquals( + Set.of(201, 200, 404, 422, 409), + statusCodes( + route( + new ApiRoutingDefinitionDocGenerator(createAndUpdate).generate(""), + RoutingVerb.PUT, + "notes/:id"))); + Assertions.assertEquals( + 405, + route( + new ApiRoutingDefinitionDocGenerator(unsupported).generate(""), + RoutingVerb.PUT, + "notes/:id") + .status() + .value()); + } + + @Test + public void httpApiAndDirectApiSharePolicyResponses() { + Thingifier thingifier = stringIdNotes(); + thingifier.apiConfig().writeMethods().entities().postCan(CREATE); + createNote(thingifier, "one", "One"); + + ApiResponse direct = post(thingifier, "notes/one", "{\"title\":\"Blocked\"}"); + ThingifierHttpApi httpApi = new ThingifierHttpApi(thingifier); + int httpStatus = + httpApi.post(jsonRequest("notes/one", "POST", "{\"title\":\"Blocked\"}")) + .getStatusCode(); + + Assertions.assertEquals(405, direct.getStatusCode()); + Assertions.assertEquals(405, httpStatus); + } + + private Thingifier stringIdNotes() { + Thingifier thingifier = new Thingifier(); + EntityDefinition note = thingifier.defineThing("note", "notes"); + note.addAsPrimaryKeyField(Field.is("id", FieldType.STRING)); + note.addField(Field.is("title", FieldType.STRING).makeMandatory()); + return thingifier; + } + + private Thingifier relationshipModel() { + Thingifier thingifier = new Thingifier(); + EntityDefinition project = thingifier.defineThing("project", "projects"); + project.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT)); + project.addField(Field.is("title", FieldType.STRING)); + + EntityDefinition task = thingifier.defineThing("task", "tasks"); + task.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT)); + task.addField(Field.is("title", FieldType.STRING).makeMandatory()); + + thingifier.defineRelationship(project, task, "tasks", Cardinality.ONE_TO_MANY()); + return thingifier; + } + + private Thingifier autoIdNotes() { + Thingifier thingifier = new Thingifier(); + EntityDefinition note = thingifier.defineThing("note", "notes"); + note.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT)); + note.addField(Field.is("title", FieldType.STRING).makeMandatory()); + return thingifier; + } + + private EntityInstance createNote( + final Thingifier thingifier, final String id, final String title) { + EntityDefinition note = thingifier.getDefinitionNamed("note"); + return thingifier + .getStore(EntityRelModel.DEFAULT_DATABASE_NAME) + .entities() + .create( + EntityInstanceDraft.forEntity(note) + .withField("id", id) + .withField("title", title)); + } + + private EntityInstance createProject(final Thingifier thingifier, final String title) { + EntityDefinition project = thingifier.getDefinitionNamed("project"); + return thingifier + .getStore(EntityRelModel.DEFAULT_DATABASE_NAME) + .entities() + .create(EntityInstanceDraft.forEntity(project).withField("title", title)); + } + + private EntityInstance createTask(final Thingifier thingifier, final String title) { + EntityDefinition task = thingifier.getDefinitionNamed("task"); + return thingifier + .getStore(EntityRelModel.DEFAULT_DATABASE_NAME) + .entities() + .create(EntityInstanceDraft.forEntity(task).withField("title", title)); + } + + private ApiResponse post(final Thingifier thingifier, final String path, final String body) { + return thingifier.api().post(path, parser(thingifier, body), new HttpHeadersBlock()); + } + + private ApiResponse put(final Thingifier thingifier, final String path, final String body) { + return thingifier.api().put(path, parser(thingifier, body), new HttpHeadersBlock()); + } + + private ApiResponse patch(final Thingifier thingifier, final String path, final String body) { + return thingifier.api().patch(path, parser(thingifier, body), new HttpHeadersBlock()); + } + + private BodyParser parser(final Thingifier thingifier, final String body) { + return new BodyParser( + new HttpApiRequest("/request").setBody(body), thingifier.getThingNames()); + } + + private HttpApiRequest jsonRequest(final String path, final String verb, final String body) { + return new HttpApiRequest(path) + .setVerb(verb) + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "application/json") + .setBody(body); + } + + private String noteJson(final String id, final String title) { + return "{\"id\":\"" + id + "\",\"title\":\"" + title + "\"}"; + } + + 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 statusCodes(final RoutingDefinition route) { + return route.getPossibleStatusReponses().stream() + .map(status -> status.value()) + .collect(Collectors.toSet()); + } +} 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 new file mode 100644 index 00000000..67881911 --- /dev/null +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/apiconfig/WriteMethodsConfigTest.java @@ -0,0 +1,64 @@ +package uk.co.compendiumdev.thingifier.apiconfig; + +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.RelationshipWriteOperation.CONNECT_EXISTING; +import static uk.co.compendiumdev.thingifier.apiconfig.RelationshipWriteOperation.CREATE_AND_CONNECT; +import static uk.co.compendiumdev.thingifier.apiconfig.RelationshipWriteOperation.DISCONNECT; + +import java.util.Set; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import uk.co.compendiumdev.thingifier.Thingifier; + +public class WriteMethodsConfigTest { + + @Test + public void defaultsPreserveExistingWriteMethodBehavior() { + WriteMethodsConfig config = new WriteMethodsConfig(); + + Assertions.assertEquals(Set.of(CREATE, UPDATE), config.entities().postOperations()); + Assertions.assertEquals(Set.of(CREATE, UPDATE), config.entities().putOperations()); + Assertions.assertEquals(Set.of(), config.entities().patchOperations()); + Assertions.assertEquals( + Set.of(CREATE_AND_CONNECT, CONNECT_EXISTING), + config.relationships().postOperations()); + Assertions.assertEquals(Set.of(DISCONNECT), config.relationships().deleteOperations()); + } + + @Test + public void emptyOperationSetMeansMethodIsNotSupported() { + WriteMethodsConfig config = new WriteMethodsConfig(); + + config.entities().postCan(); + config.relationships().deleteCan(); + + Assertions.assertEquals(Set.of(), config.entities().postOperations()); + Assertions.assertEquals(Set.of(), config.relationships().deleteOperations()); + } + + @Test + public void configCanBeCopiedBetweenProfiles() { + ThingifierApiConfig source = new ThingifierApiConfig(""); + source.writeMethods().entities().postCan(CREATE); + source.writeMethods().entities().patchCan(UPDATE); + source.writeMethods().relationships().postCan(CONNECT_EXISTING); + + ThingifierApiConfig target = new ThingifierApiConfig(""); + target.setFrom(source); + + Assertions.assertEquals(Set.of(CREATE), target.writeMethods().entities().postOperations()); + Assertions.assertEquals(Set.of(UPDATE), target.writeMethods().entities().patchOperations()); + Assertions.assertEquals( + Set.of(CONNECT_EXISTING), target.writeMethods().relationships().postOperations()); + } + + @Test + @SuppressWarnings("deprecation") + public void clearerThingifierAliasesExposeSameConfigurationObjects() { + Thingifier thingifier = new Thingifier(); + + Assertions.assertSame(thingifier.apiDefaults(), thingifier.apiConfig()); + Assertions.assertSame(thingifier.apiContract(), thingifier.apiSpec()); + } +} From da8270885869eff6af2591fc6ba0eb9343aac0b1 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Thu, 30 Jul 2026 13:17:40 +0100 Subject: [PATCH 2/2] Add configurable write method policies --- .../repository-sqlite-migration-audit.md | 0 .../ConfiguredWriteMethodsThingifier.java | 8 +- pom.xml | 2 + .../ConfiguredWriteMethodsDirectApiTest.java | 118 +++ ...nfiguredWriteMethodsDocumentationTest.java | 76 ++ .../ConfiguredWriteMethodsHttpApiTest.java | 146 +++ .../ConfiguredWriteMethodsSampleApiTest.java | 154 --- ...guredWriteMethodsSampleApiTestSupport.java | 129 +++ .../ConfiguredWriteMethodsSwaggerTest.java | 56 ++ .../crudui/CrudUiControllerTest.java | 427 ++++---- .../crudui/WorkspaceProjectServiceTest.java | 733 +++++++------- .../WorkspaceSchemaUpgradeServiceTest.java | 418 ++++---- thingifier/pom.xml | 10 + .../EntityPatchDocumentMapper.java | 240 +++++ .../apihandlers/ThingBodyCommandMapper.java | 17 +- .../apihandlers/ThingWriteRequestMapper.java | 34 + .../http/apihandlers/WriteMethodPolicy.java | 30 +- .../httpserver/ThingifierHttpApiRoutings.java | 16 + .../conversion/ThingifierHttpApiBridge.java | 4 + .../api/ThingifierRestAPIHandler.java | 14 +- .../api/docgen/RoutingDefinition.java | 42 + .../api/docgen/WriteMethodRoutePolicy.java | 57 +- .../api/http/ApiRequestEnvelope.java | 20 +- .../api/http/HttpApiRequestValidator.java | 4 +- .../api/http/ThingifierHttpApi.java | 2 +- .../api/http/bodyparser/BodyParser.java | 4 + .../headerparser/ContentTypeHeaderParser.java | 28 +- .../restapihandlers/RestApiPatchHandler.java | 46 +- .../api/spec/ThingifierApiRouteRule.java | 27 + .../api/spec/ThingifierApiSpec.java | 60 +- .../apiconfig/EntityPatchUpdateStyle.java | 42 + .../apiconfig/EntityWriteMethodConfig.java | 41 +- .../application/AmendThingHandler.java | 17 +- .../command/AmendThingCommand.java | 35 +- .../thingifier/swaggerizer/Swaggerizer.java | 34 +- .../RelationshipApiSqliteRepositoryTest.java | 941 ++++++++++-------- .../javalin/JavalinHttpServerTest.java | 336 ++++--- .../JavalinThingifierPatchRoutesTest.java | 270 +++++ .../WriteMethodPolicyTest.java | 424 +++++++- .../apiconfig/WriteMethodsConfigTest.java | 8 +- 40 files changed, 3443 insertions(+), 1627 deletions(-) rename docs/{ => ai}/repository-sqlite-migration-audit.md (100%) create mode 100644 standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsDirectApiTest.java create mode 100644 standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsDocumentationTest.java create mode 100644 standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsHttpApiTest.java delete mode 100644 standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsSampleApiTest.java create mode 100644 standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsSampleApiTestSupport.java create mode 100644 standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsSwaggerTest.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/EntityPatchDocumentMapper.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/EntityPatchUpdateStyle.java create mode 100644 thingifier/src/test/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinThingifierPatchRoutesTest.java diff --git a/docs/repository-sqlite-migration-audit.md b/docs/ai/repository-sqlite-migration-audit.md similarity index 100% rename from docs/repository-sqlite-migration-audit.md rename to docs/ai/repository-sqlite-migration-audit.md diff --git a/examplemodels/src/main/java/uk/co/compendiumdev/thingifier/application/examples/ConfiguredWriteMethodsThingifier.java b/examplemodels/src/main/java/uk/co/compendiumdev/thingifier/application/examples/ConfiguredWriteMethodsThingifier.java index 2a61b0d9..a2f279bb 100644 --- a/examplemodels/src/main/java/uk/co/compendiumdev/thingifier/application/examples/ConfiguredWriteMethodsThingifier.java +++ b/examplemodels/src/main/java/uk/co/compendiumdev/thingifier/application/examples/ConfiguredWriteMethodsThingifier.java @@ -1,5 +1,8 @@ package uk.co.compendiumdev.thingifier.application.examples; +import static uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle.JSON_MERGE_PATCH_RFC7396; +import static uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle.JSON_PATCH_RFC6902; +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.core.domain.definitions.field.definition.FieldType.STRING; @@ -25,7 +28,10 @@ public Thingifier get() { Field.is("description", STRING)); notes.apiDefaults().writeMethods().entities().postCan(CREATE); - notes.apiDefaults().writeMethods().entities().patchCan(UPDATE); + notes.apiDefaults() + .writeMethods() + .entities() + .patchCan(PARTIAL_JSON_UPDATE, JSON_MERGE_PATCH_RFC7396, JSON_PATCH_RFC6902); notes.apiDefaults().writeMethods().entities().putCan(UPDATE); return notes; diff --git a/pom.xml b/pom.xml index 1396018b..c7a63cd1 100644 --- a/pom.xml +++ b/pom.xml @@ -16,6 +16,8 @@ 1.5.6-SNAPSHOT 2.2.52 2.8.6 + 2.21.1 + 0.6.3 3.53.2.0 2.6 5.6.2 diff --git a/standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsDirectApiTest.java b/standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsDirectApiTest.java new file mode 100644 index 00000000..3dda418e --- /dev/null +++ b/standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsDirectApiTest.java @@ -0,0 +1,118 @@ +package uk.co.compendiumdev.configuredwritemethods.application; + +import static uk.co.compendiumdev.configuredwritemethods.application.ConfiguredWriteMethodsSampleApiTestSupport.createStoredNote; +import static uk.co.compendiumdev.configuredwritemethods.application.ConfiguredWriteMethodsSampleApiTestSupport.currentNote; +import static uk.co.compendiumdev.configuredwritemethods.application.ConfiguredWriteMethodsSampleApiTestSupport.noteJson; +import static uk.co.compendiumdev.configuredwritemethods.application.ConfiguredWriteMethodsSampleApiTestSupport.patch; +import static uk.co.compendiumdev.configuredwritemethods.application.ConfiguredWriteMethodsSampleApiTestSupport.post; +import static uk.co.compendiumdev.configuredwritemethods.application.ConfiguredWriteMethodsSampleApiTestSupport.put; +import static uk.co.compendiumdev.configuredwritemethods.application.ConfiguredWriteMethodsSampleApiTestSupport.sample; +import static uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle.JSON_MERGE_PATCH_RFC7396; +import static uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle.JSON_PATCH_RFC6902; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.api.response.ApiResponse; + +class ConfiguredWriteMethodsDirectApiTest { + + @Test + void postNotesCreatesANote() { + final Thingifier thingifier = sample(); + + final ApiResponse response = post(thingifier, "notes", noteJson("One")); + + Assertions.assertEquals(201, response.getStatusCode()); + Assertions.assertEquals( + "One", response.getReturnedInstance().getFieldValue("title").asString()); + Assertions.assertEquals( + "sample", response.getReturnedInstance().getFieldValue("description").asString()); + } + + @Test + void postExistingNoteIsBlocked() { + final Thingifier thingifier = sample(); + final String id = createStoredNote(thingifier).getPrimaryKeyValue(); + + final ApiResponse response = post(thingifier, "notes/" + id, "{\"title\":\"Blocked\"}"); + + Assertions.assertEquals(405, response.getStatusCode()); + Assertions.assertEquals( + "Existing", currentNote(thingifier, id).getFieldValue("title").asString()); + } + + @Test + void partialJsonPatchUpdatesTheExistingNote() { + final Thingifier thingifier = sample(); + final String id = createStoredNote(thingifier).getPrimaryKeyValue(); + + final ApiResponse response = + patch(thingifier, "notes/" + id, "{\"title\":\"Patched Direct\"}"); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals( + "Patched Direct", response.getReturnedInstance().getFieldValue("title").asString()); + Assertions.assertEquals( + "sample", response.getReturnedInstance().getFieldValue("description").asString()); + } + + @Test + void mergePatchUpdatesTheExistingNote() { + final Thingifier thingifier = sample(); + final String id = createStoredNote(thingifier).getPrimaryKeyValue(); + + final ApiResponse response = + patch( + thingifier, + "notes/" + id, + "{\"description\":\"Merged Direct\"}", + JSON_MERGE_PATCH_RFC7396.mediaType()); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals( + "Existing", response.getReturnedInstance().getFieldValue("title").asString()); + Assertions.assertEquals( + "Merged Direct", + response.getReturnedInstance().getFieldValue("description").asString()); + } + + @Test + void jsonPatchUpdatesTheExistingNote() { + final Thingifier thingifier = sample(); + final String id = createStoredNote(thingifier).getPrimaryKeyValue(); + + final ApiResponse response = + patch( + thingifier, + "notes/" + id, + "[{\"op\":\"replace\",\"path\":\"/title\",\"value\":\"Json Patch Direct\"}]", + JSON_PATCH_RFC6902.mediaType()); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals( + "Json Patch Direct", + response.getReturnedInstance().getFieldValue("title").asString()); + Assertions.assertEquals( + "sample", response.getReturnedInstance().getFieldValue("description").asString()); + } + + @Test + void putExistingNoteUpdatesTheNote() { + final Thingifier thingifier = sample(); + final String id = createStoredNote(thingifier).getPrimaryKeyValue(); + + final ApiResponse response = put(thingifier, "notes/" + id, "{\"title\":\"Put Direct\"}"); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals( + "Put Direct", response.getReturnedInstance().getFieldValue("title").asString()); + } + + @Test + void putMissingNoteIsBlockedBecausePutCannotCreate() { + final ApiResponse response = put(sample(), "notes/999", "{\"title\":\"Missing\"}"); + + Assertions.assertEquals(405, response.getStatusCode()); + } +} diff --git a/standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsDocumentationTest.java b/standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsDocumentationTest.java new file mode 100644 index 00000000..4564b9a2 --- /dev/null +++ b/standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsDocumentationTest.java @@ -0,0 +1,76 @@ +package uk.co.compendiumdev.configuredwritemethods.application; + +import static uk.co.compendiumdev.configuredwritemethods.application.ConfiguredWriteMethodsSampleApiTestSupport.documentation; +import static uk.co.compendiumdev.configuredwritemethods.application.ConfiguredWriteMethodsSampleApiTestSupport.patchFormatsHeader; +import static uk.co.compendiumdev.configuredwritemethods.application.ConfiguredWriteMethodsSampleApiTestSupport.route; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import uk.co.compendiumdev.thingifier.api.docgen.ApiRoutingDefinition; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; + +class ConfiguredWriteMethodsDocumentationTest { + + @Test + void exposesPostCreateOnTheCollectionRoute() { + final ApiRoutingDefinition definition = documentation(); + + Assertions.assertTrue( + route(definition, RoutingVerb.POST, "notes").status().isReturnedFromCall()); + } + + @Test + void blocksPostUpdateOnTheInstanceRoute() { + final ApiRoutingDefinition definition = documentation(); + + Assertions.assertEquals( + 405, route(definition, RoutingVerb.POST, "notes/:id").status().value()); + } + + @Test + void blocksPutAndPatchOnTheCollectionRoute() { + final ApiRoutingDefinition definition = documentation(); + + Assertions.assertEquals(405, route(definition, RoutingVerb.PUT, "notes").status().value()); + Assertions.assertEquals( + 405, route(definition, RoutingVerb.PATCH, "notes").status().value()); + } + + @Test + void exposesPatchAndPutUpdateOnTheInstanceRoute() { + final ApiRoutingDefinition definition = documentation(); + + Assertions.assertTrue( + route(definition, RoutingVerb.PATCH, "notes/:id").status().isReturnedFromCall()); + Assertions.assertTrue( + route(definition, RoutingVerb.PUT, "notes/:id").status().isReturnedFromCall()); + } + + @Test + void collectionOptionsAllowOnlyTheCollectionMethods() { + final ApiRoutingDefinition definition = documentation(); + + Assertions.assertEquals( + "OPTIONS, GET, HEAD, POST, QUERY", + route(definition, RoutingVerb.OPTIONS, "notes").headerValue()); + } + + @Test + void instanceOptionsAllowOnlyTheInstanceMethods() { + final ApiRoutingDefinition definition = documentation(); + + Assertions.assertEquals( + "OPTIONS, GET, HEAD, PUT, PATCH, DELETE", + route(definition, RoutingVerb.OPTIONS, "notes/:id").headerValue()); + } + + @Test + void instanceOptionsExposeConfiguredPatchMediaTypes() { + final ApiRoutingDefinition definition = documentation(); + + Assertions.assertEquals( + patchFormatsHeader(), + route(definition, RoutingVerb.OPTIONS, "notes/:id") + .getResponseHeaderValue("Accept-Patch")); + } +} diff --git a/standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsHttpApiTest.java b/standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsHttpApiTest.java new file mode 100644 index 00000000..bf9b7814 --- /dev/null +++ b/standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsHttpApiTest.java @@ -0,0 +1,146 @@ +package uk.co.compendiumdev.configuredwritemethods.application; + +import static uk.co.compendiumdev.configuredwritemethods.application.ConfiguredWriteMethodsSampleApiTestSupport.createStoredNote; +import static uk.co.compendiumdev.configuredwritemethods.application.ConfiguredWriteMethodsSampleApiTestSupport.currentNote; +import static uk.co.compendiumdev.configuredwritemethods.application.ConfiguredWriteMethodsSampleApiTestSupport.httpApi; +import static uk.co.compendiumdev.configuredwritemethods.application.ConfiguredWriteMethodsSampleApiTestSupport.httpApiFor; +import static uk.co.compendiumdev.configuredwritemethods.application.ConfiguredWriteMethodsSampleApiTestSupport.jsonRequest; +import static uk.co.compendiumdev.configuredwritemethods.application.ConfiguredWriteMethodsSampleApiTestSupport.noteJson; +import static uk.co.compendiumdev.configuredwritemethods.application.ConfiguredWriteMethodsSampleApiTestSupport.sample; +import static uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle.JSON_MERGE_PATCH_RFC7396; +import static uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle.JSON_PATCH_RFC6902; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.api.http.HttpApiResponse; + +class ConfiguredWriteMethodsHttpApiTest { + + @Test + void postNotesCreatesANote() { + final HttpApiResponse response = + httpApi().post(jsonRequest("notes", "POST", noteJson("One"))); + + Assertions.assertEquals(201, response.getStatusCode()); + Assertions.assertEquals( + "One", + response.apiResponse().getReturnedInstance().getFieldValue("title").asString()); + Assertions.assertEquals( + "sample", + response.apiResponse() + .getReturnedInstance() + .getFieldValue("description") + .asString()); + } + + @Test + void postExistingNoteIsBlocked() { + final Thingifier thingifier = sample(); + final String id = createStoredNote(thingifier).getPrimaryKeyValue(); + + final HttpApiResponse response = + httpApiFor(thingifier) + .post(jsonRequest("notes/" + id, "POST", "{\"title\":\"Blocked\"}")); + + Assertions.assertEquals(405, response.getStatusCode()); + Assertions.assertEquals( + "Existing", currentNote(thingifier, id).getFieldValue("title").asString()); + } + + @Test + void partialJsonPatchUpdatesTheExistingNote() { + final Thingifier thingifier = sample(); + final String id = createStoredNote(thingifier).getPrimaryKeyValue(); + + final HttpApiResponse response = + httpApiFor(thingifier) + .patch(jsonRequest("notes/" + id, "PATCH", "{\"title\":\"Patched\"}")); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals( + "Patched", + response.apiResponse().getReturnedInstance().getFieldValue("title").asString()); + Assertions.assertEquals( + "sample", + response.apiResponse() + .getReturnedInstance() + .getFieldValue("description") + .asString()); + } + + @Test + void mergePatchUpdatesTheExistingNote() { + final Thingifier thingifier = sample(); + final String id = createStoredNote(thingifier).getPrimaryKeyValue(); + + final HttpApiResponse response = + httpApiFor(thingifier) + .patch( + jsonRequest( + "notes/" + id, + "PATCH", + "{\"description\":\"Merged\"}", + JSON_MERGE_PATCH_RFC7396.mediaType())); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals( + "Existing", + response.apiResponse().getReturnedInstance().getFieldValue("title").asString()); + Assertions.assertEquals( + "Merged", + response.apiResponse() + .getReturnedInstance() + .getFieldValue("description") + .asString()); + } + + @Test + void jsonPatchUpdatesTheExistingNote() { + final Thingifier thingifier = sample(); + final String id = createStoredNote(thingifier).getPrimaryKeyValue(); + + final HttpApiResponse response = + httpApiFor(thingifier) + .patch( + jsonRequest( + "notes/" + id, + "PATCH", + "[{\"op\":\"replace\",\"path\":\"/title\",\"value\":\"Json Patch\"}]", + JSON_PATCH_RFC6902.mediaType())); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals( + "Json Patch", + response.apiResponse().getReturnedInstance().getFieldValue("title").asString()); + Assertions.assertEquals( + "sample", + response.apiResponse() + .getReturnedInstance() + .getFieldValue("description") + .asString()); + } + + @Test + void putExistingNoteUpdatesTheNote() { + final Thingifier thingifier = sample(); + final String id = createStoredNote(thingifier).getPrimaryKeyValue(); + + final HttpApiResponse response = + httpApiFor(thingifier) + .put(jsonRequest("notes/" + id, "PUT", "{\"title\":\"Put\"}")); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals( + "Put", + response.apiResponse().getReturnedInstance().getFieldValue("title").asString()); + } + + @Test + void putMissingNoteIsBlockedBecausePutCannotCreate() { + final HttpApiResponse response = + httpApi().put(jsonRequest("notes/999", "PUT", "{\"title\":\"Missing\"}")); + + Assertions.assertEquals(405, response.getStatusCode()); + } +} diff --git a/standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsSampleApiTest.java b/standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsSampleApiTest.java deleted file mode 100644 index ffd2a328..00000000 --- a/standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsSampleApiTest.java +++ /dev/null @@ -1,154 +0,0 @@ -package uk.co.compendiumdev.configuredwritemethods.application; - -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.PathItem; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import uk.co.compendiumdev.thingifier.Thingifier; -import uk.co.compendiumdev.thingifier.api.docgen.ApiRoutingDefinition; -import uk.co.compendiumdev.thingifier.api.docgen.ApiRoutingDefinitionDocGenerator; -import uk.co.compendiumdev.thingifier.api.docgen.RoutingDefinition; -import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; -import uk.co.compendiumdev.thingifier.api.docgen.ThingifierApiDocumentationDefn; -import uk.co.compendiumdev.thingifier.api.http.HttpApiRequest; -import uk.co.compendiumdev.thingifier.api.http.HttpApiResponse; -import uk.co.compendiumdev.thingifier.api.http.ThingifierHttpApi; -import uk.co.compendiumdev.thingifier.api.http.bodyparser.BodyParser; -import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock; -import uk.co.compendiumdev.thingifier.api.response.ApiResponse; -import uk.co.compendiumdev.thingifier.application.examples.ConfiguredWriteMethodsThingifier; -import uk.co.compendiumdev.thingifier.swaggerizer.Swaggerizer; - -class ConfiguredWriteMethodsSampleApiTest { - - @Test - void configuredMethodsAreEnforcedOverHttpApi() { - final Thingifier thingifier = sample(); - final ThingifierHttpApi httpApi = new ThingifierHttpApi(thingifier); - - final HttpApiResponse create = httpApi.post(jsonRequest("notes", "POST", noteJson("One"))); - final String id = create.apiResponse().getReturnedInstance().getPrimaryKeyValue(); - - Assertions.assertEquals(201, create.getStatusCode()); - Assertions.assertEquals( - 405, - httpApi.post(jsonRequest("notes/" + id, "POST", "{\"title\":\"Blocked\"}")) - .getStatusCode()); - Assertions.assertEquals( - 200, - httpApi.patch(jsonRequest("notes/" + id, "PATCH", "{\"title\":\"Patched\"}")) - .getStatusCode()); - Assertions.assertEquals( - 200, - httpApi.put(jsonRequest("notes/" + id, "PUT", "{\"title\":\"Put\"}")) - .getStatusCode()); - Assertions.assertEquals( - 405, - httpApi.put(jsonRequest("notes/999", "PUT", "{\"title\":\"Missing\"}")) - .getStatusCode()); - } - - @Test - void directApiReceivesTheSamePolicyResponses() { - final Thingifier thingifier = sample(); - final ApiResponse create = post(thingifier, "notes", noteJson("One")); - final String id = create.getReturnedInstance().getPrimaryKeyValue(); - - Assertions.assertEquals(201, create.getStatusCode()); - Assertions.assertEquals( - 405, post(thingifier, "notes/" + id, "{\"title\":\"Blocked\"}").getStatusCode()); - - final ApiResponse patch = - patch(thingifier, "notes/" + id, "{\"title\":\"Patched Direct\"}"); - Assertions.assertEquals(200, patch.getStatusCode()); - Assertions.assertEquals( - "Patched Direct", patch.getReturnedInstance().getFieldValue("title").asString()); - - final ApiResponse put = put(thingifier, "notes/" + id, "{\"title\":\"Put Direct\"}"); - Assertions.assertEquals(200, put.getStatusCode()); - Assertions.assertEquals( - "Put Direct", put.getReturnedInstance().getFieldValue("title").asString()); - Assertions.assertEquals( - 405, put(thingifier, "notes/999", "{\"title\":\"Missing\"}").getStatusCode()); - } - - @Test - void documentationAndSwaggerExposeOnlyAllowedWriteMethods() { - final Thingifier thingifier = sample(); - final ApiRoutingDefinition definition = - new ApiRoutingDefinitionDocGenerator(thingifier).generate(""); - - Assertions.assertTrue( - route(definition, RoutingVerb.POST, "notes").status().isReturnedFromCall()); - Assertions.assertEquals( - 405, route(definition, RoutingVerb.POST, "notes/:id").status().value()); - Assertions.assertEquals(405, route(definition, RoutingVerb.PUT, "notes").status().value()); - Assertions.assertEquals( - 405, route(definition, RoutingVerb.PATCH, "notes").status().value()); - Assertions.assertTrue( - route(definition, RoutingVerb.PATCH, "notes/:id").status().isReturnedFromCall()); - Assertions.assertTrue( - route(definition, RoutingVerb.PUT, "notes/:id").status().isReturnedFromCall()); - Assertions.assertEquals( - "OPTIONS, GET, HEAD, POST, QUERY", - route(definition, RoutingVerb.OPTIONS, "notes").headerValue()); - Assertions.assertEquals( - "OPTIONS, GET, HEAD, PUT, PATCH, DELETE", - route(definition, RoutingVerb.OPTIONS, "notes/:id").headerValue()); - - final OpenAPI openApi = - new Swaggerizer(new ThingifierApiDocumentationDefn().setThingifier(thingifier)) - .swagger(); - final PathItem collection = openApi.getPaths().get("/notes"); - final PathItem instance = openApi.getPaths().get("/notes/{id}"); - - Assertions.assertNotNull(collection.getPost()); - Assertions.assertNull(collection.getPut()); - Assertions.assertNull(collection.getPatch()); - Assertions.assertNull(instance.getPost()); - Assertions.assertNotNull(instance.getPatch()); - Assertions.assertNotNull(instance.getPut()); - } - - private Thingifier sample() { - return new ConfiguredWriteMethodsThingifier().get(); - } - - private ApiResponse post(final Thingifier thingifier, final String path, final String body) { - return thingifier.api().post(path, parser(thingifier, body), new HttpHeadersBlock()); - } - - private ApiResponse put(final Thingifier thingifier, final String path, final String body) { - return thingifier.api().put(path, parser(thingifier, body), new HttpHeadersBlock()); - } - - private ApiResponse patch(final Thingifier thingifier, final String path, final String body) { - return thingifier.api().patch(path, parser(thingifier, body), new HttpHeadersBlock()); - } - - private BodyParser parser(final Thingifier thingifier, final String body) { - return new BodyParser( - new HttpApiRequest("/request").setBody(body), thingifier.getThingNames()); - } - - private HttpApiRequest jsonRequest(final String path, final String verb, final String body) { - return new HttpApiRequest(path) - .setVerb(verb) - .addHeader("Content-Type", "application/json") - .addHeader("Accept", "application/json") - .setBody(body); - } - - private String noteJson(final String title) { - return "{\"title\":\"" + title + "\",\"description\":\"sample\"}"; - } - - 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(); - } -} diff --git a/standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsSampleApiTestSupport.java b/standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsSampleApiTestSupport.java new file mode 100644 index 00000000..778c3d6e --- /dev/null +++ b/standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsSampleApiTestSupport.java @@ -0,0 +1,129 @@ +package uk.co.compendiumdev.configuredwritemethods.application; + +import static uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle.JSON_MERGE_PATCH_RFC7396; +import static uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle.JSON_PATCH_RFC6902; +import static uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle.PARTIAL_JSON_UPDATE; + +import io.swagger.v3.oas.models.OpenAPI; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.api.docgen.ApiRoutingDefinition; +import uk.co.compendiumdev.thingifier.api.docgen.ApiRoutingDefinitionDocGenerator; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingDefinition; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; +import uk.co.compendiumdev.thingifier.api.docgen.ThingifierApiDocumentationDefn; +import uk.co.compendiumdev.thingifier.api.http.HttpApiRequest; +import uk.co.compendiumdev.thingifier.api.http.ThingifierHttpApi; +import uk.co.compendiumdev.thingifier.api.http.bodyparser.BodyParser; +import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock; +import uk.co.compendiumdev.thingifier.api.response.ApiResponse; +import uk.co.compendiumdev.thingifier.application.examples.ConfiguredWriteMethodsThingifier; +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.domain.instances.EntityInstanceDraft; +import uk.co.compendiumdev.thingifier.swaggerizer.Swaggerizer; + +final class ConfiguredWriteMethodsSampleApiTestSupport { + + private ConfiguredWriteMethodsSampleApiTestSupport() {} + + static Thingifier sample() { + return new ConfiguredWriteMethodsThingifier().get(); + } + + static ThingifierHttpApi httpApi() { + return httpApiFor(sample()); + } + + static ThingifierHttpApi httpApiFor(final Thingifier thingifier) { + return new ThingifierHttpApi(thingifier); + } + + static ApiRoutingDefinition documentation() { + return new ApiRoutingDefinitionDocGenerator(sample()).generate(""); + } + + static OpenAPI swagger() { + return new Swaggerizer(new ThingifierApiDocumentationDefn().setThingifier(sample())) + .swagger(); + } + + static EntityInstance createStoredNote(final Thingifier thingifier) { + final EntityDefinition note = thingifier.getDefinitionNamed("note"); + return thingifier + .getStore(EntityRelModel.DEFAULT_DATABASE_NAME) + .entities() + .create( + EntityInstanceDraft.forEntity(note) + .withField("title", "Existing") + .withField("description", "sample")); + } + + static EntityInstance currentNote(final Thingifier thingifier, final String id) { + return thingifier + .getStore(EntityRelModel.DEFAULT_DATABASE_NAME) + .entityQueries() + .findByPrimaryKey(thingifier.getDefinitionNamed("note"), id); + } + + static ApiResponse post(final Thingifier thingifier, final String path, final String body) { + return thingifier.api().post(path, parser(thingifier, body), new HttpHeadersBlock()); + } + + static ApiResponse put(final Thingifier thingifier, final String path, final String body) { + return thingifier.api().put(path, parser(thingifier, body), new HttpHeadersBlock()); + } + + static ApiResponse patch(final Thingifier thingifier, final String path, final String body) { + return patch(thingifier, path, body, PARTIAL_JSON_UPDATE.mediaType()); + } + + static ApiResponse patch( + final Thingifier thingifier, + final String path, + final String body, + final String contentType) { + HttpHeadersBlock headers = new HttpHeadersBlock(); + headers.put("Content-Type", contentType); + return thingifier.api().patch(path, body, headers); + } + + static HttpApiRequest jsonRequest(final String path, final String verb, final String body) { + return jsonRequest(path, verb, body, PARTIAL_JSON_UPDATE.mediaType()); + } + + static HttpApiRequest jsonRequest( + final String path, final String verb, final String body, final String contentType) { + return new HttpApiRequest(path) + .setVerb(verb) + .addHeader("Content-Type", contentType) + .addHeader("Accept", "application/json") + .setBody(body); + } + + static String noteJson(final String title) { + return "{\"title\":\"" + title + "\",\"description\":\"sample\"}"; + } + + static String patchFormatsHeader() { + return String.join( + ", ", + PARTIAL_JSON_UPDATE.mediaType(), + JSON_MERGE_PATCH_RFC7396.mediaType(), + JSON_PATCH_RFC6902.mediaType()); + } + + static 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 static BodyParser parser(final Thingifier thingifier, final String body) { + return new BodyParser( + new HttpApiRequest("/request").setBody(body), thingifier.getThingNames()); + } +} diff --git a/standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsSwaggerTest.java b/standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsSwaggerTest.java new file mode 100644 index 00000000..2cd70c34 --- /dev/null +++ b/standAloneConfiguredWriteMethodsRestApi/src/test/java/uk/co/compendiumdev/configuredwritemethods/application/ConfiguredWriteMethodsSwaggerTest.java @@ -0,0 +1,56 @@ +package uk.co.compendiumdev.configuredwritemethods.application; + +import static uk.co.compendiumdev.configuredwritemethods.application.ConfiguredWriteMethodsSampleApiTestSupport.swagger; +import static uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle.JSON_MERGE_PATCH_RFC7396; +import static uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle.JSON_PATCH_RFC6902; +import static uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle.PARTIAL_JSON_UPDATE; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.PathItem; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class ConfiguredWriteMethodsSwaggerTest { + + @Test + void exposesPostCreateOnlyOnTheCollectionRoute() { + final OpenAPI openApi = swagger(); + final PathItem collection = openApi.getPaths().get("/notes"); + + Assertions.assertNotNull(collection.getPost()); + Assertions.assertNull(collection.getPut()); + Assertions.assertNull(collection.getPatch()); + } + + @Test + void exposesPatchAndPutUpdateOnlyOnTheInstanceRoute() { + final OpenAPI openApi = swagger(); + final PathItem instance = openApi.getPaths().get("/notes/{id}"); + + Assertions.assertNull(instance.getPost()); + Assertions.assertNotNull(instance.getPatch()); + Assertions.assertNotNull(instance.getPut()); + } + + @Test + void patchRequestBodyExposesAllConfiguredPatchMediaTypes() { + final OpenAPI openApi = swagger(); + final PathItem instance = openApi.getPaths().get("/notes/{id}"); + + Assertions.assertTrue( + instance.getPatch() + .getRequestBody() + .getContent() + .containsKey(PARTIAL_JSON_UPDATE.mediaType())); + Assertions.assertTrue( + instance.getPatch() + .getRequestBody() + .getContent() + .containsKey(JSON_MERGE_PATCH_RFC7396.mediaType())); + Assertions.assertTrue( + instance.getPatch() + .getRequestBody() + .getContent() + .containsKey(JSON_PATCH_RFC6902.mediaType())); + } +} diff --git a/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/CrudUiControllerTest.java b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/CrudUiControllerTest.java index a818667f..63ae59e9 100644 --- a/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/CrudUiControllerTest.java +++ b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/CrudUiControllerTest.java @@ -4,7 +4,9 @@ import com.google.gson.JsonParser; import java.nio.file.Path; import java.util.Map; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import uk.co.compendiumdev.thingifier.swaggerizer.OpenApiSpecificationVersion; @@ -13,188 +15,161 @@ public class CrudUiControllerTest { @TempDir Path temp; + private ActiveThingifierWorkspace workspace; + private CrudUiController controller; + + @BeforeEach + public void createWorkspace() { + workspace = ActiveThingifierWorkspace.defaultTodoManagerWorkspace(); + controller = new CrudUiController(workspace); + } + + @AfterEach + public void closeWorkspace() { + workspace.close(); + } + @Test public void workspaceRouteReturnsSchemaMetadata() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - CrudUiController controller = new CrudUiController(workspace); - - UiHttpResponse response = controller.workspace(); - - Assertions.assertEquals(200, response.statusCode(), response.body()); - Assertions.assertTrue(response.body().contains("\"entities\"")); - Assertions.assertTrue(response.body().contains("\"relationships\"")); - Assertions.assertTrue(response.body().contains("\"schemaYaml\"")); - Assertions.assertTrue(response.body().contains("\"project\"")); - Assertions.assertTrue(response.body().contains("\"storage\"")); - } + UiHttpResponse response = controller.workspace(); + + Assertions.assertEquals(200, response.statusCode(), response.body()); + Assertions.assertTrue(response.body().contains("\"entities\"")); + Assertions.assertTrue(response.body().contains("\"relationships\"")); + Assertions.assertTrue(response.body().contains("\"schemaYaml\"")); + Assertions.assertTrue(response.body().contains("\"project\"")); + Assertions.assertTrue(response.body().contains("\"storage\"")); } @Test public void storageSwitchEndpointChangesWorkspaceStorageMode() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - CrudUiController controller = new CrudUiController(workspace); - Path databaseFile = temp.resolve("controller.sqlite"); - - UiHttpResponse response = - controller.switchStorage( - JsonSupport.toJson( - Map.of( - "mode", - "sqlite-file", - "sqliteFile", - databaseFile.toString()))); - - Assertions.assertEquals(200, response.statusCode(), response.body()); - Assertions.assertEquals("sqlite-file", workspace.snapshot().storage().mode()); - Assertions.assertTrue(response.body().contains("\"storageStatus\": \"switched\"")); - } + Path databaseFile = temp.resolve("controller.sqlite"); + + UiHttpResponse response = + controller.switchStorage( + JsonSupport.toJson( + Map.of( + "mode", + "sqlite-file", + "sqliteFile", + databaseFile.toString()))); + + Assertions.assertEquals(200, response.statusCode(), response.body()); + Assertions.assertEquals("sqlite-file", workspace.snapshot().storage().mode()); + Assertions.assertTrue(response.body().contains("\"storageStatus\": \"switched\"")); } @Test public void projectBrowseEndpointReturnsSelectedPathFromChooser() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - Path chosen = temp.resolve("chosen-project"); - CrudUiController controller = - new CrudUiController( - workspace, request -> ProjectPathSelection.selected(chosen.toString())); - - UiHttpResponse response = - controller.browseProject( - JsonSupport.toJson(Map.of("action", "save", "path", ""))); - JsonObject body = JsonParser.parseString(response.body()).getAsJsonObject(); - - Assertions.assertEquals(200, response.statusCode()); - Assertions.assertTrue(body.get("selected").getAsBoolean()); - Assertions.assertEquals(chosen.toString(), body.get("path").getAsString()); - } + Path chosen = temp.resolve("chosen-project"); + CrudUiController browsingController = + new CrudUiController( + workspace, request -> ProjectPathSelection.selected(chosen.toString())); + + UiHttpResponse response = + browsingController.browseProject( + JsonSupport.toJson(Map.of("action", "save", "path", ""))); + JsonObject body = JsonParser.parseString(response.body()).getAsJsonObject(); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertTrue(body.get("selected").getAsBoolean()); + Assertions.assertEquals(chosen.toString(), body.get("path").getAsString()); } @Test public void projectBrowseEndpointReturnsCancelledSelection() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - CrudUiController controller = - new CrudUiController(workspace, request -> ProjectPathSelection.cancelled()); - - UiHttpResponse response = - controller.browseProject( - JsonSupport.toJson(Map.of("action", "load", "path", ""))); - JsonObject body = JsonParser.parseString(response.body()).getAsJsonObject(); - - Assertions.assertEquals(200, response.statusCode()); - Assertions.assertFalse(body.get("selected").getAsBoolean()); - Assertions.assertEquals( - "Project browsing cancelled.", body.get("message").getAsString()); - } + CrudUiController browsingController = + new CrudUiController(workspace, request -> ProjectPathSelection.cancelled()); + + UiHttpResponse response = + browsingController.browseProject( + JsonSupport.toJson(Map.of("action", "load", "path", ""))); + JsonObject body = JsonParser.parseString(response.body()).getAsJsonObject(); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertFalse(body.get("selected").getAsBoolean()); + Assertions.assertEquals("Project browsing cancelled.", body.get("message").getAsString()); } @Test public void projectBrowseEndpointReportsUnavailableChooserAsBadRequest() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - CrudUiController controller = - new CrudUiController( - workspace, - request -> ProjectPathSelection.unavailable("Browse unavailable")); - - UiHttpResponse response = - controller.browseProject( - JsonSupport.toJson(Map.of("action", "save", "path", ""))); - - Assertions.assertEquals(400, response.statusCode()); - Assertions.assertTrue(response.body().contains("Browse unavailable")); - } + CrudUiController browsingController = + new CrudUiController( + workspace, + request -> ProjectPathSelection.unavailable("Browse unavailable")); + + UiHttpResponse response = + browsingController.browseProject( + JsonSupport.toJson(Map.of("action", "save", "path", ""))); + + Assertions.assertEquals(400, response.statusCode()); + Assertions.assertTrue(response.body().contains("Browse unavailable")); } @Test public void sqliteFileStorageSwitchRequiresAFilePathAndDoesNotMutateWorkspace() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - CrudUiController controller = new CrudUiController(workspace); - long version = workspace.snapshot().version(); - - UiHttpResponse response = - controller.switchStorage(JsonSupport.toJson(Map.of("mode", "sqlite-file"))); - - Assertions.assertEquals(400, response.statusCode()); - Assertions.assertEquals(version, workspace.snapshot().version()); - Assertions.assertEquals("memory", workspace.snapshot().storage().mode()); - } + long version = workspace.snapshot().version(); + + UiHttpResponse response = + controller.switchStorage(JsonSupport.toJson(Map.of("mode", "sqlite-file"))); + + Assertions.assertEquals(400, response.statusCode()); + Assertions.assertEquals(version, workspace.snapshot().version()); + Assertions.assertEquals("memory", workspace.snapshot().storage().mode()); } @Test public void invalidYamlDoesNotReplaceWorkspace() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - CrudUiController controller = new CrudUiController(workspace); - - UiHttpResponse response = controller.loadYaml("formatVersion: 1\nentities: ["); + UiHttpResponse response = controller.loadYaml("formatVersion: 1\nentities: ["); - Assertions.assertEquals(400, response.statusCode()); - Assertions.assertEquals("Todo Manager", workspace.snapshot().definition().title()); - } + Assertions.assertEquals(400, response.statusCode()); + Assertions.assertEquals("Todo Manager", workspace.snapshot().definition().title()); } @Test public void schemaYamlPreviewDoesNotReplaceWorkspace() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - CrudUiController controller = new CrudUiController(workspace); - long version = workspace.snapshot().version(); - String title = workspace.snapshot().definition().title(); - - UiHttpResponse response = - controller.schemaFromYaml(TestResources.text("/models/minimal-todo.yaml")); - JsonObject body = JsonParser.parseString(response.body()).getAsJsonObject(); - - Assertions.assertEquals(200, response.statusCode()); - Assertions.assertTrue(body.get("valid").getAsBoolean()); - Assertions.assertEquals(version, workspace.snapshot().version()); - Assertions.assertEquals(title, workspace.snapshot().definition().title()); - } + long version = workspace.snapshot().version(); + String title = workspace.snapshot().definition().title(); + + UiHttpResponse response = + controller.schemaFromYaml(TestResources.text("/models/minimal-todo.yaml")); + JsonObject body = JsonParser.parseString(response.body()).getAsJsonObject(); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertTrue(body.get("valid").getAsBoolean()); + Assertions.assertEquals(version, workspace.snapshot().version()); + Assertions.assertEquals(title, workspace.snapshot().definition().title()); } @Test public void schemaDraftPreviewDoesNotReplaceWorkspace() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - CrudUiController controller = new CrudUiController(workspace); - JsonObject parsed = - JsonParser.parseString( - controller - .schemaFromYaml( - TestResources.text("/models/minimal-todo.yaml")) - .body()) - .getAsJsonObject(); - long version = workspace.snapshot().version(); - - UiHttpResponse response = controller.previewSchema(parsed.get("draft").toString()); - - Assertions.assertEquals(200, response.statusCode()); - Assertions.assertTrue( - JsonParser.parseString(response.body()) - .getAsJsonObject() - .get("valid") - .getAsBoolean()); - Assertions.assertEquals(version, workspace.snapshot().version()); - Assertions.assertEquals("Todo Manager", workspace.snapshot().definition().title()); - } + JsonObject parsed = + JsonParser.parseString( + controller + .schemaFromYaml( + TestResources.text("/models/minimal-todo.yaml")) + .body()) + .getAsJsonObject(); + long version = workspace.snapshot().version(); + + UiHttpResponse response = controller.previewSchema(parsed.get("draft").toString()); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertTrue( + JsonParser.parseString(response.body()) + .getAsJsonObject() + .get("valid") + .getAsBoolean()); + Assertions.assertEquals(version, workspace.snapshot().version()); + Assertions.assertEquals("Todo Manager", workspace.snapshot().definition().title()); } @Test public void malformedSchemaYamlPreviewReturnsBadRequest() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - CrudUiController controller = new CrudUiController(workspace); + UiHttpResponse response = controller.schemaFromYaml("formatVersion: 1\nentities: ["); - UiHttpResponse response = controller.schemaFromYaml("formatVersion: 1\nentities: ["); - - Assertions.assertEquals(400, response.statusCode()); - Assertions.assertEquals("Todo Manager", workspace.snapshot().definition().title()); - } + Assertions.assertEquals(400, response.statusCode()); + Assertions.assertEquals("Todo Manager", workspace.snapshot().definition().title()); } @Test @@ -287,124 +262,96 @@ public void staticSchemaResourceIsAvailable() { @Test public void openApiJsonUsesActiveWorkspaceApiServer() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); - CrudUiController controller = new CrudUiController(workspace); - - UiHttpResponse response = controller.openApiJson(); - - Assertions.assertEquals(200, response.statusCode()); - Assertions.assertEquals("application/json", response.contentType()); - Assertions.assertTrue(response.body().contains("\"openapi\"")); - Assertions.assertTrue(openApiVersion(response.body()).startsWith("3.1.")); - Assertions.assertTrue(response.body().contains("\"/api\"")); - Assertions.assertTrue(response.body().contains("\"/projects\"")); - Assertions.assertTrue(response.body().contains("\"/projects/{id}/tasks\"")); - } + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + + UiHttpResponse response = controller.openApiJson(); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertEquals("application/json", response.contentType()); + Assertions.assertTrue(response.body().contains("\"openapi\"")); + Assertions.assertTrue(openApiVersion(response.body()).startsWith("3.1.")); + Assertions.assertTrue(response.body().contains("\"/api\"")); + Assertions.assertTrue(response.body().contains("\"/projects\"")); + Assertions.assertTrue(response.body().contains("\"/projects/{id}/tasks\"")); } @Test public void openApiJsonCanBeGeneratedForOpenApi30() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); - CrudUiController controller = new CrudUiController(workspace); - - UiHttpResponse response = - controller.openApiJson(OpenApiSpecificationVersion.OPENAPI_3_0); - - Assertions.assertEquals(200, response.statusCode()); - Assertions.assertEquals("application/json", response.contentType()); - Assertions.assertTrue(openApiVersion(response.body()).startsWith("3.0.")); - Assertions.assertTrue(response.body().contains("\"/api\"")); - Assertions.assertTrue(response.body().contains("\"/projects/{id}/tasks\"")); - } + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + + UiHttpResponse response = controller.openApiJson(OpenApiSpecificationVersion.OPENAPI_3_0); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertEquals("application/json", response.contentType()); + Assertions.assertTrue(openApiVersion(response.body()).startsWith("3.0.")); + Assertions.assertTrue(response.body().contains("\"/api\"")); + Assertions.assertTrue(response.body().contains("\"/projects/{id}/tasks\"")); } @Test public void openApiJsonCanBeGeneratedForOpenApi32() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); - CrudUiController controller = new CrudUiController(workspace); - - UiHttpResponse response = - controller.openApiJson(OpenApiSpecificationVersion.OPENAPI_3_2); - - Assertions.assertEquals(200, response.statusCode()); - Assertions.assertEquals("application/json", response.contentType()); - Assertions.assertEquals("3.2.0", openApiVersion(response.body())); - Assertions.assertTrue(response.body().contains("\"/api\"")); - Assertions.assertTrue(response.body().contains("\"/projects/{id}/tasks\"")); - Assertions.assertTrue(response.body().contains("\"query\"")); - Assertions.assertFalse(response.body().contains("\"x-query-operation\"")); - } + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + + UiHttpResponse response = controller.openApiJson(OpenApiSpecificationVersion.OPENAPI_3_2); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertEquals("application/json", response.contentType()); + Assertions.assertEquals("3.2.0", openApiVersion(response.body())); + Assertions.assertTrue(response.body().contains("\"/api\"")); + Assertions.assertTrue(response.body().contains("\"/projects/{id}/tasks\"")); + Assertions.assertTrue(response.body().contains("\"query\"")); + Assertions.assertFalse(response.body().contains("\"x-query-operation\"")); } @Test public void openApiDownloadAddsAttachmentHeader() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); - CrudUiController controller = new CrudUiController(workspace); - - UiHttpResponse response = controller.downloadOpenApi(false); - - Assertions.assertEquals(200, response.statusCode()); - Assertions.assertEquals("application/json", response.contentType()); - Assertions.assertEquals( - "attachment; filename=\"project-tasks-openapi.json\"", - response.headers().get("Content-Disposition")); - Assertions.assertTrue(response.body().contains("\"Project Tasks\"")); - } + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + + UiHttpResponse response = controller.downloadOpenApi(false); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertEquals("application/json", response.contentType()); + Assertions.assertEquals( + "attachment; filename=\"project-tasks-openapi.json\"", + response.headers().get("Content-Disposition")); + Assertions.assertTrue(response.body().contains("\"Project Tasks\"")); } @Test public void apiDocumentationPageListsSchemaAndGeneratedRoutes() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); - CrudUiController controller = new CrudUiController(workspace); - - UiHttpResponse response = controller.apiDocumentationPage(); - - Assertions.assertEquals(200, response.statusCode()); - Assertions.assertEquals("text/html", response.contentType()); - Assertions.assertTrue(response.body().contains("Project Tasks API Documentation")); - Assertions.assertTrue(response.body().contains("href=\"/favicon.svg\"")); - Assertions.assertTrue(response.body().contains("/api/projects")); - Assertions.assertTrue(response.body().contains("/api/projects/{id}/tasks")); - Assertions.assertTrue(response.body().contains("Download OpenAPI")); - } + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + + UiHttpResponse response = controller.apiDocumentationPage(); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertEquals("text/html", response.contentType()); + Assertions.assertTrue(response.body().contains("Project Tasks API Documentation")); + Assertions.assertTrue(response.body().contains("href=\"/favicon.svg\"")); + Assertions.assertTrue(response.body().contains("/api/projects")); + Assertions.assertTrue(response.body().contains("/api/projects/{id}/tasks")); + Assertions.assertTrue(response.body().contains("Download OpenAPI")); } @Test public void swaggerUiPageEmbedsUnpkgSwaggerUiForCurrentOpenApiJson() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - CrudUiController controller = new CrudUiController(workspace); - - UiHttpResponse response = controller.swaggerUi(); - - Assertions.assertEquals(200, response.statusCode()); - Assertions.assertTrue( - response.body().contains("https://unpkg.com/swagger-ui-dist/swagger-ui.css")); - Assertions.assertTrue( - response.body() - .contains("https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js")); - Assertions.assertTrue(response.body().contains("href=\"/favicon.svg\"")); - Assertions.assertTrue(response.body().contains("SwaggerUIBundle")); - Assertions.assertTrue(response.body().contains("/openapi.json")); - Assertions.assertTrue(response.body().contains("/openapi-3.1.json")); - Assertions.assertTrue(response.body().contains("/openapi-3.2.json")); - Assertions.assertTrue(response.body().contains("/openapi-3.0.json")); - Assertions.assertTrue(response.body().contains("/css/swagger-copy-for-ai.css")); - Assertions.assertTrue(response.body().contains("/js/swagger-copy-for-ai.js")); - Assertions.assertTrue(response.body().contains("window.thingifierSwaggerCopyForAi")); - Assertions.assertTrue(response.body().contains("openApiUrl: \"/openapi.json\"")); - Assertions.assertTrue(response.body().contains("\"urls.primaryName\"")); - } + UiHttpResponse response = controller.swaggerUi(); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertTrue( + response.body().contains("https://unpkg.com/swagger-ui-dist/swagger-ui.css")); + Assertions.assertTrue( + response.body().contains("https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js")); + Assertions.assertTrue(response.body().contains("href=\"/favicon.svg\"")); + Assertions.assertTrue(response.body().contains("SwaggerUIBundle")); + Assertions.assertTrue(response.body().contains("/openapi.json")); + Assertions.assertTrue(response.body().contains("/openapi-3.1.json")); + Assertions.assertTrue(response.body().contains("/openapi-3.2.json")); + Assertions.assertTrue(response.body().contains("/openapi-3.0.json")); + Assertions.assertTrue(response.body().contains("/css/swagger-copy-for-ai.css")); + Assertions.assertTrue(response.body().contains("/js/swagger-copy-for-ai.js")); + Assertions.assertTrue(response.body().contains("window.thingifierSwaggerCopyForAi")); + Assertions.assertTrue(response.body().contains("openApiUrl: \"/openapi.json\"")); + Assertions.assertTrue(response.body().contains("\"urls.primaryName\"")); } @Test diff --git a/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceProjectServiceTest.java b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceProjectServiceTest.java index b37a72cb..0f391823 100644 --- a/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceProjectServiceTest.java +++ b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceProjectServiceTest.java @@ -19,175 +19,173 @@ public class WorkspaceProjectServiceTest { @Test public void saveCreatesProjectManifestSchemaAndDataFiles() throws Exception { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); - createRelatedProjectAndTodo(new DynamicThingifierApiProxy(workspace)); - CrudUiController controller = new CrudUiController(workspace); - Path projectFolder = temp.resolve("project-bundle"); - - UiHttpResponse response = controller.saveProject(request(projectFolder)); - - Assertions.assertEquals(200, response.statusCode()); - Assertions.assertTrue(Files.exists(projectFolder.resolve("projectfile.erproj"))); - Assertions.assertTrue(Files.exists(projectFolder.resolve("schema.yaml"))); - Assertions.assertTrue(Files.exists(projectFolder.resolve("data.json"))); - Assertions.assertFalse( - Files.readString(projectFolder.resolve("data.json")).contains("schemaYaml")); - Assertions.assertTrue(response.body().contains("\"projectStatus\": \"saved\"")); - Assertions.assertTrue(response.body().contains("\"active\": true")); - } + withDefaultWorkspace( + (workspace, controller, proxy) -> { + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + createRelatedProjectAndTodo(proxy); + Path projectFolder = temp.resolve("project-bundle"); + + UiHttpResponse response = controller.saveProject(request(projectFolder)); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertTrue( + Files.exists(projectFolder.resolve("projectfile.erproj"))); + Assertions.assertTrue(Files.exists(projectFolder.resolve("schema.yaml"))); + Assertions.assertTrue(Files.exists(projectFolder.resolve("data.json"))); + Assertions.assertFalse( + Files.readString(projectFolder.resolve("data.json")) + .contains("schemaYaml")); + Assertions.assertTrue(response.body().contains("\"projectStatus\": \"saved\"")); + Assertions.assertTrue(response.body().contains("\"active\": true")); + }); } @Test - public void savedProjectReloadsSchemaDataAndRelationshipEdges() { + public void savedProjectReloadsSchemaDataAndRelationshipEdges() throws Exception { Path projectFolder = temp.resolve("project-bundle"); - try (ActiveThingifierWorkspace source = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - source.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); - createRelatedProjectAndTodo(new DynamicThingifierApiProxy(source)); - new CrudUiController(source).saveProject(request(projectFolder)); - } - - try (ActiveThingifierWorkspace target = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - CrudUiController controller = new CrudUiController(target); - - UiHttpResponse response = controller.loadProject(request(projectFolder)); - DynamicThingifierApiProxy targetProxy = new DynamicThingifierApiProxy(target); - - Assertions.assertEquals(200, response.statusCode()); - Assertions.assertTrue(response.body().contains("\"projectStatus\": \"loaded\"")); - Assertions.assertEquals("Project Tasks", target.snapshot().definition().title()); - Assertions.assertTrue(targetProxy.getJson("projects").body().contains("Project A")); - Assertions.assertTrue(targetProxy.getJson("todos").body().contains("Task A")); - Assertions.assertEquals( - 1, - root(targetProxy.getJson("projects/1/tasks").body()) - .getAsJsonArray("todos") - .size()); - } + withDefaultWorkspace( + (source, controller, proxy) -> { + source.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + createRelatedProjectAndTodo(proxy); + controller.saveProject(request(projectFolder)); + }); + + withDefaultWorkspace( + (target, controller, proxy) -> { + UiHttpResponse response = controller.loadProject(request(projectFolder)); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertTrue( + response.body().contains("\"projectStatus\": \"loaded\"")); + Assertions.assertEquals( + "Project Tasks", target.snapshot().definition().title()); + Assertions.assertTrue(proxy.getJson("projects").body().contains("Project A")); + Assertions.assertTrue(proxy.getJson("todos").body().contains("Task A")); + Assertions.assertEquals( + 1, + root(proxy.getJson("projects/1/tasks").body()) + .getAsJsonArray("todos") + .size()); + }); } @Test public void sqliteBackedSaveCreatesManifestSchemaAndDatabaseFile() throws Exception { Path projectFolder = temp.resolve("sqlite-project-bundle"); - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); - createRelatedProjectAndTodo(new DynamicThingifierApiProxy(workspace)); - workspace.switchStorage(WorkspaceStorage.sqliteFile(temp.resolve("source.sqlite"))); - - UiHttpResponse response = - new CrudUiController(workspace).saveProject(request(projectFolder)); - - Assertions.assertEquals(200, response.statusCode()); - Assertions.assertTrue(Files.exists(projectFolder.resolve("projectfile.erproj"))); - Assertions.assertTrue(Files.exists(projectFolder.resolve("schema.yaml"))); - Assertions.assertTrue(Files.exists(projectFolder.resolve("data.sqlite"))); - Assertions.assertFalse(Files.exists(projectFolder.resolve("data.json"))); - Assertions.assertTrue( - Files.readString(projectFolder.resolve("projectfile.erproj")) - .contains("dataFile: data.sqlite")); - Assertions.assertFalse( - Files.readString(projectFolder.resolve("projectfile.erproj")) - .contains("storage:")); - } + withDefaultWorkspace( + (workspace, controller, proxy) -> { + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + createRelatedProjectAndTodo(proxy); + workspace.switchStorage( + WorkspaceStorage.sqliteFile(temp.resolve("source.sqlite"))); + + UiHttpResponse response = controller.saveProject(request(projectFolder)); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertTrue( + Files.exists(projectFolder.resolve("projectfile.erproj"))); + Assertions.assertTrue(Files.exists(projectFolder.resolve("schema.yaml"))); + Assertions.assertTrue(Files.exists(projectFolder.resolve("data.sqlite"))); + Assertions.assertFalse(Files.exists(projectFolder.resolve("data.json"))); + Assertions.assertTrue( + Files.readString(projectFolder.resolve("projectfile.erproj")) + .contains("dataFile: data.sqlite")); + Assertions.assertFalse( + Files.readString(projectFolder.resolve("projectfile.erproj")) + .contains("storage:")); + }); } @Test public void saveAsSqliteFromMemorySwitchesWorkspaceToProjectDatabaseFile() throws Exception { Path projectFolder = temp.resolve("sqlite-save-as-project"); - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); - createRelatedProjectAndTodo(new DynamicThingifierApiProxy(workspace)); - - UiHttpResponse response = - new CrudUiController(workspace).saveProject(request(projectFolder, "sqlite")); - - Assertions.assertEquals(200, response.statusCode(), response.body()); - Assertions.assertTrue(Files.exists(projectFolder.resolve("data.sqlite"))); - Assertions.assertFalse(Files.exists(projectFolder.resolve("data.json"))); - Assertions.assertEquals("sqlite-file", workspace.snapshot().storage().mode()); - Assertions.assertEquals( - projectFolder.resolve("data.sqlite").toAbsolutePath().normalize().toString(), - workspace.snapshot().storage().sqliteFilePath()); - Assertions.assertTrue( - new DynamicThingifierApiProxy(workspace) - .getJson("projects") - .body() - .contains("Project A")); - } + withDefaultWorkspace( + (workspace, controller, proxy) -> { + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + createRelatedProjectAndTodo(proxy); + + UiHttpResponse response = + controller.saveProject(request(projectFolder, "sqlite")); + + Assertions.assertEquals(200, response.statusCode(), response.body()); + Assertions.assertTrue(Files.exists(projectFolder.resolve("data.sqlite"))); + Assertions.assertFalse(Files.exists(projectFolder.resolve("data.json"))); + Assertions.assertEquals("sqlite-file", workspace.snapshot().storage().mode()); + Assertions.assertEquals( + projectFolder + .resolve("data.sqlite") + .toAbsolutePath() + .normalize() + .toString(), + workspace.snapshot().storage().sqliteFilePath()); + Assertions.assertTrue(proxy.getJson("projects").body().contains("Project A")); + }); } @Test public void saveAsJsonFromSqliteSwitchesWorkspaceToInMemoryStorage() throws Exception { Path projectFolder = temp.resolve("json-save-as-project"); - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); - createRelatedProjectAndTodo(new DynamicThingifierApiProxy(workspace)); - workspace.switchStorage(WorkspaceStorage.sqliteFile(temp.resolve("source.sqlite"))); - - UiHttpResponse response = - new CrudUiController(workspace).saveProject(request(projectFolder, "json")); - - Assertions.assertEquals(200, response.statusCode(), response.body()); - Assertions.assertTrue(Files.exists(projectFolder.resolve("data.json"))); - Assertions.assertFalse( - Files.readString(projectFolder.resolve("projectfile.erproj")) - .contains("sqlite-file")); - Assertions.assertEquals("memory", workspace.snapshot().storage().mode()); - Assertions.assertTrue( - new DynamicThingifierApiProxy(workspace) - .getJson("projects") - .body() - .contains("Project A")); - } + withDefaultWorkspace( + (workspace, controller, proxy) -> { + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + createRelatedProjectAndTodo(proxy); + workspace.switchStorage( + WorkspaceStorage.sqliteFile(temp.resolve("source.sqlite"))); + + UiHttpResponse response = + controller.saveProject(request(projectFolder, "json")); + + Assertions.assertEquals(200, response.statusCode(), response.body()); + Assertions.assertTrue(Files.exists(projectFolder.resolve("data.json"))); + Assertions.assertFalse( + Files.readString(projectFolder.resolve("projectfile.erproj")) + .contains("sqlite-file")); + Assertions.assertEquals("memory", workspace.snapshot().storage().mode()); + Assertions.assertTrue(proxy.getJson("projects").body().contains("Project A")); + }); } @Test - public void sqliteBackedProjectReloadsDataAndSwitchesStorageMode() { + public void sqliteBackedProjectReloadsDataAndSwitchesStorageMode() throws Exception { Path projectFolder = temp.resolve("sqlite-project-bundle"); - try (ActiveThingifierWorkspace source = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - source.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); - createRelatedProjectAndTodo(new DynamicThingifierApiProxy(source)); - source.switchStorage(WorkspaceStorage.sqliteFile(temp.resolve("source.sqlite"))); - new CrudUiController(source).saveProject(request(projectFolder)); - } - - try (ActiveThingifierWorkspace target = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - UiHttpResponse response = - new CrudUiController(target).loadProject(request(projectFolder)); - DynamicThingifierApiProxy targetProxy = new DynamicThingifierApiProxy(target); - - Assertions.assertEquals(200, response.statusCode()); - Assertions.assertEquals("sqlite-file", target.snapshot().storage().mode()); - Assertions.assertTrue( - target.snapshot().storage().sqliteFilePath().endsWith("data.sqlite")); - Assertions.assertTrue(targetProxy.getJson("projects").body().contains("Project A")); - Assertions.assertEquals( - 1, - root(targetProxy.getJson("projects/1/tasks").body()) - .getAsJsonArray("todos") - .size()); - } + withDefaultWorkspace( + (source, controller, proxy) -> { + source.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + createRelatedProjectAndTodo(proxy); + source.switchStorage( + WorkspaceStorage.sqliteFile(temp.resolve("source.sqlite"))); + controller.saveProject(request(projectFolder)); + }); + + withDefaultWorkspace( + (target, controller, proxy) -> { + UiHttpResponse response = controller.loadProject(request(projectFolder)); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertEquals("sqlite-file", target.snapshot().storage().mode()); + Assertions.assertTrue( + target.snapshot().storage().sqliteFilePath().endsWith("data.sqlite")); + Assertions.assertTrue(proxy.getJson("projects").body().contains("Project A")); + Assertions.assertEquals( + 1, + root(proxy.getJson("projects/1/tasks").body()) + .getAsJsonArray("todos") + .size()); + }); } @Test public void loadProjectTreatsSqliteDataFileAsSqliteStorageWithoutStorageBlock() throws Exception { Path projectFolder = temp.resolve("edited-sqlite-project-bundle"); - try (ActiveThingifierWorkspace source = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - source.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); - createRelatedProjectAndTodo(new DynamicThingifierApiProxy(source)); - source.switchStorage(WorkspaceStorage.sqliteFile(temp.resolve("source.sqlite"))); - new CrudUiController(source).saveProject(request(projectFolder)); - } + withDefaultWorkspace( + (source, controller, proxy) -> { + source.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + createRelatedProjectAndTodo(proxy); + source.switchStorage( + WorkspaceStorage.sqliteFile(temp.resolve("source.sqlite"))); + controller.saveProject(request(projectFolder)); + }); Files.move( projectFolder.resolve("data.sqlite"), projectFolder.resolve("todomanager.sqlite")); Files.writeString( @@ -198,258 +196,268 @@ public void loadProjectTreatsSqliteDataFileAsSqliteStorageWithoutStorageBlock() + "schemaFile: schema.yaml\n" + "dataFile: todomanager.sqlite\n"); - try (ActiveThingifierWorkspace target = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - UiHttpResponse response = - new CrudUiController(target).loadProject(request(projectFolder)); - DynamicThingifierApiProxy targetProxy = new DynamicThingifierApiProxy(target); - - Assertions.assertEquals(200, response.statusCode(), response.body()); - Assertions.assertEquals("sqlite-file", target.snapshot().storage().mode()); - Assertions.assertTrue( - target.snapshot().storage().sqliteFilePath().endsWith("todomanager.sqlite")); - Assertions.assertTrue(targetProxy.getJson("projects").body().contains("Project A")); - } + withDefaultWorkspace( + (target, controller, proxy) -> { + UiHttpResponse response = controller.loadProject(request(projectFolder)); + + Assertions.assertEquals(200, response.statusCode(), response.body()); + Assertions.assertEquals("sqlite-file", target.snapshot().storage().mode()); + Assertions.assertTrue( + target.snapshot() + .storage() + .sqliteFilePath() + .endsWith("todomanager.sqlite")); + Assertions.assertTrue(proxy.getJson("projects").body().contains("Project A")); + }); } @Test public void loadProjectSniffsSqliteDataFileWhenFilenameHasNoSqliteExtension() throws Exception { Path projectFolder = temp.resolve("sniffed-sqlite-project-bundle"); - try (ActiveThingifierWorkspace source = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - source.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); - createRelatedProjectAndTodo(new DynamicThingifierApiProxy(source)); - source.switchStorage(WorkspaceStorage.sqliteFile(temp.resolve("source.sqlite"))); - new CrudUiController(source).saveProject(request(projectFolder)); - } + withDefaultWorkspace( + (source, controller, proxy) -> { + source.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + createRelatedProjectAndTodo(proxy); + source.switchStorage( + WorkspaceStorage.sqliteFile(temp.resolve("source.sqlite"))); + controller.saveProject(request(projectFolder)); + }); Files.move(projectFolder.resolve("data.sqlite"), projectFolder.resolve("data.bin")); Files.writeString( projectFolder.resolve("projectfile.erproj"), "formatVersion: 1\n" + "schemaFile: schema.yaml\n" + "dataFile: data.bin\n"); - try (ActiveThingifierWorkspace target = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - UiHttpResponse response = - new CrudUiController(target).loadProject(request(projectFolder)); + withDefaultWorkspace( + (target, controller, proxy) -> { + UiHttpResponse response = controller.loadProject(request(projectFolder)); - Assertions.assertEquals(200, response.statusCode(), response.body()); - Assertions.assertEquals("sqlite-file", target.snapshot().storage().mode()); - Assertions.assertTrue( - target.snapshot().storage().sqliteFilePath().endsWith("data.bin")); - } + Assertions.assertEquals(200, response.statusCode(), response.body()); + Assertions.assertEquals("sqlite-file", target.snapshot().storage().mode()); + Assertions.assertTrue( + target.snapshot().storage().sqliteFilePath().endsWith("data.bin")); + }); } @Test - public void loadAcceptsDirectProjectFilePath() { + public void loadAcceptsDirectProjectFilePath() throws Exception { Path projectFolder = temp.resolve("project-bundle"); - try (ActiveThingifierWorkspace source = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - source.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); - new CrudUiController(source).saveProject(request(projectFolder)); - } - - try (ActiveThingifierWorkspace target = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - UiHttpResponse response = - new CrudUiController(target) - .loadProject(request(projectFolder.resolve("projectfile.erproj"))); - - Assertions.assertEquals(200, response.statusCode()); - Assertions.assertEquals("Project Tasks", target.snapshot().definition().title()); - } + withDefaultWorkspace( + (source, controller, proxy) -> { + source.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + controller.saveProject(request(projectFolder)); + }); + + withDefaultWorkspace( + (target, controller, proxy) -> { + UiHttpResponse response = + controller.loadProject( + request(projectFolder.resolve("projectfile.erproj"))); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertEquals( + "Project Tasks", target.snapshot().definition().title()); + }); } @Test public void checkSavePathReportsCreatableFolderAndManagedFiles() throws Exception { Path creatableFolder = temp.resolve("new-project"); - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - CrudUiController controller = new CrudUiController(workspace); - - JsonObject creatable = - root(controller.checkProject(actionRequest("save", creatableFolder)).body()); - - Assertions.assertTrue(creatable.get("canProceed").getAsBoolean()); - Assertions.assertEquals("creatable-folder", creatable.get("kind").getAsString()); - - Path existingFolder = temp.resolve("existing-project"); - Files.createDirectories(existingFolder); - Files.writeString(existingFolder.resolve("projectfile.erproj"), "managed"); - Files.writeString(existingFolder.resolve("schema.yaml"), "managed"); - - JsonObject existing = - root(controller.checkProject(actionRequest("save", existingFolder)).body()); - - Assertions.assertTrue(existing.get("canProceed").getAsBoolean()); - Assertions.assertTrue( - existing.get("warning").getAsString().contains("projectfile.erproj")); - Assertions.assertTrue(existing.get("warning").getAsString().contains("schema.yaml")); - } + withDefaultWorkspace( + (workspace, controller, proxy) -> { + JsonObject creatable = + root( + controller + .checkProject(actionRequest("save", creatableFolder)) + .body()); + + Assertions.assertTrue(creatable.get("canProceed").getAsBoolean()); + Assertions.assertEquals( + "creatable-folder", creatable.get("kind").getAsString()); + + Path existingFolder = temp.resolve("existing-project"); + Files.createDirectories(existingFolder); + Files.writeString(existingFolder.resolve("projectfile.erproj"), "managed"); + Files.writeString(existingFolder.resolve("schema.yaml"), "managed"); + + JsonObject existing = + root( + controller + .checkProject(actionRequest("save", existingFolder)) + .body()); + + Assertions.assertTrue(existing.get("canProceed").getAsBoolean()); + Assertions.assertTrue( + existing.get("warning").getAsString().contains("projectfile.erproj")); + Assertions.assertTrue( + existing.get("warning").getAsString().contains("schema.yaml")); + }); } @Test - public void checkLoadPathAcceptsFolderAndDirectProjectFile() { + public void checkLoadPathAcceptsFolderAndDirectProjectFile() throws Exception { Path projectFolder = temp.resolve("project-bundle"); - try (ActiveThingifierWorkspace source = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - new CrudUiController(source).saveProject(request(projectFolder)); - } - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - CrudUiController controller = new CrudUiController(workspace); - - JsonObject folderCheck = - root(controller.checkProject(actionRequest("load", projectFolder)).body()); - JsonObject fileCheck = - root( - controller - .checkProject( - actionRequest( - "load", - projectFolder.resolve("projectfile.erproj"))) - .body()); - - Assertions.assertTrue(folderCheck.get("canProceed").getAsBoolean()); - Assertions.assertEquals("folder", folderCheck.get("kind").getAsString()); - Assertions.assertTrue(fileCheck.get("canProceed").getAsBoolean()); - Assertions.assertEquals("project-file", fileCheck.get("kind").getAsString()); - } + withDefaultWorkspace( + (source, controller, proxy) -> controller.saveProject(request(projectFolder))); + + withDefaultWorkspace( + (workspace, controller, proxy) -> { + JsonObject folderCheck = + root( + controller + .checkProject(actionRequest("load", projectFolder)) + .body()); + JsonObject fileCheck = + root( + controller + .checkProject( + actionRequest( + "load", + projectFolder.resolve( + "projectfile.erproj"))) + .body()); + + Assertions.assertTrue(folderCheck.get("canProceed").getAsBoolean()); + Assertions.assertEquals("folder", folderCheck.get("kind").getAsString()); + Assertions.assertTrue(fileCheck.get("canProceed").getAsBoolean()); + Assertions.assertEquals("project-file", fileCheck.get("kind").getAsString()); + }); } @Test - public void browserExportFilesEmitsJsonBackedProjectFiles() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - UiHttpResponse response = new CrudUiController(workspace).exportProjectFiles(); - JsonObject body = root(response.body()); - - Assertions.assertEquals(200, response.statusCode()); - Assertions.assertTrue(response.body().contains("\"projectStatus\": \"exported\"")); - Assertions.assertTrue(response.body().contains("\"projectfile.erproj\"")); - Assertions.assertTrue(response.body().contains("\"schema.yaml\"")); - Assertions.assertTrue(response.body().contains("\"data.json\"")); - Assertions.assertFalse(response.body().contains("\"data.sqlite\"")); - Assertions.assertEquals("memory", body.get("storageMode").getAsString()); - Assertions.assertEquals("json", body.get("projectStorageMode").getAsString()); - } + public void browserExportFilesEmitsJsonBackedProjectFiles() throws Exception { + withDefaultWorkspace( + (workspace, controller, proxy) -> { + UiHttpResponse response = controller.exportProjectFiles(); + JsonObject body = root(response.body()); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertTrue( + response.body().contains("\"projectStatus\": \"exported\"")); + Assertions.assertTrue(response.body().contains("\"projectfile.erproj\"")); + Assertions.assertTrue(response.body().contains("\"schema.yaml\"")); + Assertions.assertTrue(response.body().contains("\"data.json\"")); + Assertions.assertFalse(response.body().contains("\"data.sqlite\"")); + Assertions.assertEquals("memory", body.get("storageMode").getAsString()); + Assertions.assertEquals("json", body.get("projectStorageMode").getAsString()); + }); } @Test - public void browserExportFilesEmitsSqliteBackedProjectFiles() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - workspace.switchStorage(WorkspaceStorage.sqliteFile(temp.resolve("source.sqlite"))); - - UiHttpResponse response = new CrudUiController(workspace).exportProjectFiles(); - JsonObject body = root(response.body()); - - Assertions.assertEquals(200, response.statusCode()); - Assertions.assertTrue(response.body().contains("\"projectfile.erproj\"")); - Assertions.assertTrue(response.body().contains("\"schema.yaml\"")); - Assertions.assertTrue(response.body().contains("\"data.sqlite\"")); - Assertions.assertFalse(response.body().contains("\"data.json\"")); - Assertions.assertEquals("sqlite-file", body.get("storageMode").getAsString()); - Assertions.assertEquals("sqlite", body.get("projectStorageMode").getAsString()); - } + public void browserExportFilesEmitsSqliteBackedProjectFiles() throws Exception { + withDefaultWorkspace( + (workspace, controller, proxy) -> { + workspace.switchStorage( + WorkspaceStorage.sqliteFile(temp.resolve("source.sqlite"))); + + UiHttpResponse response = controller.exportProjectFiles(); + JsonObject body = root(response.body()); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertTrue(response.body().contains("\"projectfile.erproj\"")); + Assertions.assertTrue(response.body().contains("\"schema.yaml\"")); + Assertions.assertTrue(response.body().contains("\"data.sqlite\"")); + Assertions.assertFalse(response.body().contains("\"data.json\"")); + Assertions.assertEquals("sqlite-file", body.get("storageMode").getAsString()); + Assertions.assertEquals("sqlite", body.get("projectStorageMode").getAsString()); + }); } @Test - public void browserExportFilesCanExportSqliteProjectFromMemoryWorkspace() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - UiHttpResponse response = - new CrudUiController(workspace) - .exportProjectFiles( + public void browserExportFilesCanExportSqliteProjectFromMemoryWorkspace() throws Exception { + withDefaultWorkspace( + (workspace, controller, proxy) -> { + UiHttpResponse response = + controller.exportProjectFiles( JsonSupport.toJson(Map.of("projectStorageMode", "sqlite"))); - JsonObject body = root(response.body()); - - Assertions.assertEquals(200, response.statusCode()); - Assertions.assertTrue(response.body().contains("\"data.sqlite\"")); - Assertions.assertFalse(response.body().contains("\"data.json\"")); - Assertions.assertEquals("sqlite-file", body.get("storageMode").getAsString()); - Assertions.assertEquals("sqlite", body.get("projectStorageMode").getAsString()); - Assertions.assertEquals("memory", workspace.snapshot().storage().mode()); - } + JsonObject body = root(response.body()); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertTrue(response.body().contains("\"data.sqlite\"")); + Assertions.assertFalse(response.body().contains("\"data.json\"")); + Assertions.assertEquals("sqlite-file", body.get("storageMode").getAsString()); + Assertions.assertEquals("sqlite", body.get("projectStorageMode").getAsString()); + Assertions.assertEquals("memory", workspace.snapshot().storage().mode()); + }); } @Test public void browserLoadFilesLoadsJsonBackedProjectWithoutServerPath() throws Exception { Path projectFolder = temp.resolve("browser-json-project"); - try (ActiveThingifierWorkspace source = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - source.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); - createRelatedProjectAndTodo(new DynamicThingifierApiProxy(source)); - new CrudUiController(source).saveProject(request(projectFolder)); - } - - try (ActiveThingifierWorkspace target = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - UiHttpResponse response = - new CrudUiController(target) - .loadProjectFiles(browserFilePayload(projectFolder, "browser-json")); - DynamicThingifierApiProxy targetProxy = new DynamicThingifierApiProxy(target); - - Assertions.assertEquals(200, response.statusCode(), response.body()); - Assertions.assertEquals("Project Tasks", target.snapshot().definition().title()); - Assertions.assertEquals( - "Browser folder: browser-json", target.snapshot().projectPath()); - Assertions.assertTrue(targetProxy.getJson("projects").body().contains("Project A")); - Assertions.assertEquals( - 1, - root(targetProxy.getJson("projects/1/tasks").body()) - .getAsJsonArray("todos") - .size()); - } + withDefaultWorkspace( + (source, controller, proxy) -> { + source.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + createRelatedProjectAndTodo(proxy); + controller.saveProject(request(projectFolder)); + }); + + withDefaultWorkspace( + (target, controller, proxy) -> { + UiHttpResponse response = + controller.loadProjectFiles( + browserFilePayload(projectFolder, "browser-json")); + + Assertions.assertEquals(200, response.statusCode(), response.body()); + Assertions.assertEquals( + "Project Tasks", target.snapshot().definition().title()); + Assertions.assertEquals( + "Browser folder: browser-json", target.snapshot().projectPath()); + Assertions.assertTrue(proxy.getJson("projects").body().contains("Project A")); + Assertions.assertEquals( + 1, + root(proxy.getJson("projects/1/tasks").body()) + .getAsJsonArray("todos") + .size()); + }); } @Test public void browserLoadFilesLoadsSqliteBackedProject() throws Exception { Path projectFolder = temp.resolve("browser-sqlite-project"); - try (ActiveThingifierWorkspace source = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - source.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); - createRelatedProjectAndTodo(new DynamicThingifierApiProxy(source)); - source.switchStorage(WorkspaceStorage.sqliteFile(temp.resolve("source.sqlite"))); - new CrudUiController(source).saveProject(request(projectFolder)); - } - - try (ActiveThingifierWorkspace target = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - UiHttpResponse response = - new CrudUiController(target) - .loadProjectFiles(browserFilePayload(projectFolder, "browser-sqlite")); - DynamicThingifierApiProxy targetProxy = new DynamicThingifierApiProxy(target); - - Assertions.assertEquals(200, response.statusCode(), response.body()); - Assertions.assertEquals("sqlite-file", target.snapshot().storage().mode()); - Assertions.assertEquals( - "Browser folder: browser-sqlite", target.snapshot().projectPath()); - Assertions.assertTrue(targetProxy.getJson("projects").body().contains("Project A")); - Assertions.assertEquals( - 1, - root(targetProxy.getJson("projects/1/tasks").body()) - .getAsJsonArray("todos") - .size()); - } + withDefaultWorkspace( + (source, controller, proxy) -> { + source.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + createRelatedProjectAndTodo(proxy); + source.switchStorage( + WorkspaceStorage.sqliteFile(temp.resolve("source.sqlite"))); + controller.saveProject(request(projectFolder)); + }); + + withDefaultWorkspace( + (target, controller, proxy) -> { + UiHttpResponse response = + controller.loadProjectFiles( + browserFilePayload(projectFolder, "browser-sqlite")); + + Assertions.assertEquals(200, response.statusCode(), response.body()); + Assertions.assertEquals("sqlite-file", target.snapshot().storage().mode()); + Assertions.assertEquals( + "Browser folder: browser-sqlite", target.snapshot().projectPath()); + Assertions.assertTrue(proxy.getJson("projects").body().contains("Project A")); + Assertions.assertEquals( + 1, + root(proxy.getJson("projects/1/tasks").body()) + .getAsJsonArray("todos") + .size()); + }); } @Test - public void invalidBrowserLoadFilesDoesNotMutateWorkspace() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - long version = workspace.snapshot().version(); - List> files = new ArrayList<>(); - files.add(textPayload("projectfile.erproj", "not: valid: yaml")); - UiHttpResponse response = - new CrudUiController(workspace) - .loadProjectFiles( + public void invalidBrowserLoadFilesDoesNotMutateWorkspace() throws Exception { + withDefaultWorkspace( + (workspace, controller, proxy) -> { + long version = workspace.snapshot().version(); + List> files = new ArrayList<>(); + files.add(textPayload("projectfile.erproj", "not: valid: yaml")); + UiHttpResponse response = + controller.loadProjectFiles( JsonSupport.toJson( Map.of("folderName", "bad-project", "files", files))); - Assertions.assertEquals(400, response.statusCode()); - Assertions.assertEquals(version, workspace.snapshot().version()); - Assertions.assertEquals("Todo Manager", workspace.snapshot().definition().title()); - } + Assertions.assertEquals(400, response.statusCode()); + Assertions.assertEquals(version, workspace.snapshot().version()); + Assertions.assertEquals( + "Todo Manager", workspace.snapshot().definition().title()); + }); } @Test @@ -464,17 +472,17 @@ public void invalidProjectLoadDoesNotMutateActiveWorkspace() throws Exception { TestResources.text("/models/project-tasks.yaml")); Files.writeString(projectFolder.resolve("data.json"), "{"); - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - long version = workspace.snapshot().version(); + withDefaultWorkspace( + (workspace, controller, proxy) -> { + long version = workspace.snapshot().version(); - UiHttpResponse response = - new CrudUiController(workspace).loadProject(request(projectFolder)); + UiHttpResponse response = controller.loadProject(request(projectFolder)); - Assertions.assertEquals(400, response.statusCode()); - Assertions.assertEquals(version, workspace.snapshot().version()); - Assertions.assertEquals("Todo Manager", workspace.snapshot().definition().title()); - } + Assertions.assertEquals(400, response.statusCode()); + Assertions.assertEquals(version, workspace.snapshot().version()); + Assertions.assertEquals( + "Todo Manager", workspace.snapshot().definition().title()); + }); } @Test @@ -484,12 +492,20 @@ public void saveLeavesUnrelatedFilesUntouched() throws Exception { Path extraFile = projectFolder.resolve("validators.jar"); Files.writeString(extraFile, "not really a jar"); + withDefaultWorkspace( + (workspace, controller, proxy) -> controller.saveProject(request(projectFolder))); + + Assertions.assertEquals("not really a jar", Files.readString(extraFile)); + } + + private void withDefaultWorkspace(final WorkspaceScenario scenario) throws Exception { try (ActiveThingifierWorkspace workspace = ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - new CrudUiController(workspace).saveProject(request(projectFolder)); + scenario.run( + workspace, + new CrudUiController(workspace), + new DynamicThingifierApiProxy(workspace)); } - - Assertions.assertEquals("not really a jar", Files.readString(extraFile)); } private void createRelatedProjectAndTodo(final DynamicThingifierApiProxy proxy) { @@ -556,4 +572,13 @@ private String field(final String json, final String fieldName) { private JsonObject root(final String json) { return JsonParser.parseString(json).getAsJsonObject(); } + + @FunctionalInterface + private interface WorkspaceScenario { + void run( + ActiveThingifierWorkspace workspace, + CrudUiController controller, + DynamicThingifierApiProxy proxy) + throws Exception; + } } diff --git a/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceSchemaUpgradeServiceTest.java b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceSchemaUpgradeServiceTest.java index a6d9320d..c656e8f4 100644 --- a/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceSchemaUpgradeServiceTest.java +++ b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceSchemaUpgradeServiceTest.java @@ -3,278 +3,238 @@ import com.google.gson.JsonArray; import com.google.gson.JsonObject; import com.google.gson.JsonParser; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; public class WorkspaceSchemaUpgradeServiceTest { + private ActiveThingifierWorkspace workspace; + private CrudUiController controller; + private DynamicThingifierApiProxy proxy; + + @BeforeEach + public void createWorkspace() { + workspace = ActiveThingifierWorkspace.defaultTodoManagerWorkspace(); + controller = new CrudUiController(workspace); + proxy = new DynamicThingifierApiProxy(workspace); + } + + @AfterEach + public void closeWorkspace() { + workspace.close(); + } + @Test public void previewDoesNotMutateWorkspaceSchemaVersionOrData() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); - DynamicThingifierApiProxy proxy = new DynamicThingifierApiProxy(workspace); - createProject(proxy, "Project A"); - CrudUiController controller = new CrudUiController(workspace); - long version = workspace.snapshot().version(); - - UiHttpResponse response = - controller.previewSchemaUpgrade( - upgradeRequest(draftFromYaml("/models/minimal-todo.yaml"), null, null)); - JsonObject body = json(response); - - Assertions.assertEquals(200, response.statusCode(), response.body()); - Assertions.assertTrue(body.get("valid").getAsBoolean()); - Assertions.assertTrue(body.get("canApply").getAsBoolean()); - Assertions.assertEquals(version, workspace.snapshot().version()); - Assertions.assertEquals("Project Tasks", workspace.snapshot().definition().title()); - Assertions.assertTrue(proxy.getJson("projects").body().contains("Project A")); - } + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + createProject(proxy, "Project A"); + long version = workspace.snapshot().version(); + + UiHttpResponse response = + controller.previewSchemaUpgrade( + upgradeRequest(draftFromYaml("/models/minimal-todo.yaml"), null, null)); + JsonObject body = json(response); + + Assertions.assertEquals(200, response.statusCode(), response.body()); + Assertions.assertTrue(body.get("valid").getAsBoolean()); + Assertions.assertTrue(body.get("canApply").getAsBoolean()); + Assertions.assertEquals(version, workspace.snapshot().version()); + Assertions.assertEquals("Project Tasks", workspace.snapshot().definition().title()); + Assertions.assertTrue(proxy.getJson("projects").body().contains("Project A")); } @Test public void applyRejectsInvalidSchemaDraft() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); - CrudUiController controller = new CrudUiController(workspace); - JsonObject draft = draftFromYaml("/models/project-tasks.yaml"); - entityNamed(draft, "todo").addProperty("primaryKey", "missing"); - long version = workspace.snapshot().version(); - - UiHttpResponse response = - controller.applySchemaUpgrade(upgradeRequest(draft, version, new JsonObject())); - JsonObject body = json(response); - - Assertions.assertEquals(400, response.statusCode()); - Assertions.assertFalse(body.get("valid").getAsBoolean()); - Assertions.assertEquals(version, workspace.snapshot().version()); - Assertions.assertTrue(body.get("errors").toString().contains("primaryKey")); - } + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + JsonObject draft = draftFromYaml("/models/project-tasks.yaml"); + entityNamed(draft, "todo").addProperty("primaryKey", "missing"); + long version = workspace.snapshot().version(); + + UiHttpResponse response = + controller.applySchemaUpgrade(upgradeRequest(draft, version, new JsonObject())); + JsonObject body = json(response); + + Assertions.assertEquals(400, response.statusCode()); + Assertions.assertFalse(body.get("valid").getAsBoolean()); + Assertions.assertEquals(version, workspace.snapshot().version()); + Assertions.assertTrue(body.get("errors").toString().contains("primaryKey")); } @Test public void applyRejectsStaleWorkspaceVersion() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); - long staleVersion = workspace.snapshot().version(); - workspace.replaceWithYaml(TestResources.text("/models/minimal-todo.yaml")); - CrudUiController controller = new CrudUiController(workspace); - - UiHttpResponse response = - controller.applySchemaUpgrade( - upgradeRequest( - draftFromYaml("/models/minimal-todo.yaml"), - staleVersion, - null)); - - Assertions.assertEquals(409, response.statusCode()); - Assertions.assertTrue(response.body().contains("Workspace changed")); - } + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + long staleVersion = workspace.snapshot().version(); + workspace.replaceWithYaml(TestResources.text("/models/minimal-todo.yaml")); + + UiHttpResponse response = + controller.applySchemaUpgrade( + upgradeRequest( + draftFromYaml("/models/minimal-todo.yaml"), staleVersion, null)); + + Assertions.assertEquals(409, response.statusCode()); + Assertions.assertTrue(response.body().contains("Workspace changed")); } @Test public void addFieldWithDefaultMigratesExistingRowsAndPreservesAutoKey() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); - DynamicThingifierApiProxy proxy = new DynamicThingifierApiProxy(workspace); - String todoId = createTodo(proxy, "Task A"); - CrudUiController controller = new CrudUiController(workspace); - JsonObject draft = draftFromYaml("/models/project-tasks.yaml"); - JsonArray fields = entityNamed(draft, "todo").getAsJsonArray("fields"); - fields.add(field("status", "string", "open")); - - UiHttpResponse response = - controller.applySchemaUpgrade( - upgradeRequest(draft, workspace.snapshot().version(), null)); - JsonObject body = json(response); - - Assertions.assertEquals(200, response.statusCode(), response.body()); - Assertions.assertTrue(body.get("canApply").getAsBoolean()); - Assertions.assertTrue( - proxy.getJson("todos/" + todoId).body().contains("\"status\":\"open\"")); - Assertions.assertEquals( - "2", - fieldValue(proxy.postJson("todos", "{\"title\":\"Task B\"}").body(), "id")); - } + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + String todoId = createTodo(proxy, "Task A"); + JsonObject draft = draftFromYaml("/models/project-tasks.yaml"); + JsonArray fields = entityNamed(draft, "todo").getAsJsonArray("fields"); + fields.add(field("status", "string", "open")); + + UiHttpResponse response = + controller.applySchemaUpgrade( + upgradeRequest(draft, workspace.snapshot().version(), null)); + JsonObject body = json(response); + + Assertions.assertEquals(200, response.statusCode(), response.body()); + Assertions.assertTrue(body.get("canApply").getAsBoolean()); + Assertions.assertTrue( + proxy.getJson("todos/" + todoId).body().contains("\"status\":\"open\"")); + Assertions.assertEquals( + "2", fieldValue(proxy.postJson("todos", "{\"title\":\"Task B\"}").body(), "id")); } @Test public void droppedFieldIsRemovedAndReported() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); - DynamicThingifierApiProxy proxy = new DynamicThingifierApiProxy(workspace); - createTodo(proxy, "Task A"); - CrudUiController controller = new CrudUiController(workspace); - JsonObject draft = draftFromYaml("/models/project-tasks.yaml"); - JsonArray fields = entityNamed(draft, "todo").getAsJsonArray("fields"); - fields.remove(1); - - UiHttpResponse response = - controller.applySchemaUpgrade( - upgradeRequest(draft, workspace.snapshot().version(), null)); - - Assertions.assertEquals(200, response.statusCode(), response.body()); - Assertions.assertTrue( - response.body().contains("Source field todo.title will be dropped")); - Assertions.assertFalse(proxy.getJson("todos").body().contains("Task A")); - } + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + createTodo(proxy, "Task A"); + JsonObject draft = draftFromYaml("/models/project-tasks.yaml"); + JsonArray fields = entityNamed(draft, "todo").getAsJsonArray("fields"); + fields.remove(1); + + UiHttpResponse response = + controller.applySchemaUpgrade( + upgradeRequest(draft, workspace.snapshot().version(), null)); + + Assertions.assertEquals(200, response.statusCode(), response.body()); + Assertions.assertTrue(response.body().contains("Source field todo.title will be dropped")); + Assertions.assertFalse(proxy.getJson("todos").body().contains("Task A")); } @Test public void manualEntityAndFieldMappingsPreserveRenamedRows() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - workspace.replaceWithYaml(TestResources.text("/models/minimal-todo.yaml")); - DynamicThingifierApiProxy proxy = new DynamicThingifierApiProxy(workspace); - String id = createTodo(proxy, "Task A"); - CrudUiController controller = new CrudUiController(workspace); - JsonObject draft = draftFromYaml("/models/minimal-todo.yaml"); - JsonObject todo = entityNamed(draft, "todo"); - todo.addProperty("name", "item"); - todo.addProperty("plural", "items"); - fieldNamed(todo, "title").addProperty("name", "name"); - JsonObject mappings = new JsonObject(); - JsonObject entityMappings = new JsonObject(); - entityMappings.addProperty("item", "todo"); - JsonObject fieldMappings = new JsonObject(); - JsonObject itemFieldMappings = new JsonObject(); - itemFieldMappings.addProperty("name", "title"); - fieldMappings.add("item", itemFieldMappings); - mappings.add("entityMappings", entityMappings); - mappings.add("fieldMappings", fieldMappings); - - UiHttpResponse response = - controller.applySchemaUpgrade( - upgradeRequest(draft, workspace.snapshot().version(), mappings)); - - Assertions.assertEquals(200, response.statusCode(), response.body()); - Assertions.assertTrue( - proxy.getJson("items/" + id).body().contains("\"name\":\"Task A\"")); - Assertions.assertEquals(404, proxy.getJson("todos").statusCode()); - } + workspace.replaceWithYaml(TestResources.text("/models/minimal-todo.yaml")); + String id = createTodo(proxy, "Task A"); + JsonObject draft = draftFromYaml("/models/minimal-todo.yaml"); + JsonObject todo = entityNamed(draft, "todo"); + todo.addProperty("name", "item"); + todo.addProperty("plural", "items"); + fieldNamed(todo, "title").addProperty("name", "name"); + JsonObject mappings = new JsonObject(); + JsonObject entityMappings = new JsonObject(); + entityMappings.addProperty("item", "todo"); + JsonObject fieldMappings = new JsonObject(); + JsonObject itemFieldMappings = new JsonObject(); + itemFieldMappings.addProperty("name", "title"); + fieldMappings.add("item", itemFieldMappings); + mappings.add("entityMappings", entityMappings); + mappings.add("fieldMappings", fieldMappings); + + UiHttpResponse response = + controller.applySchemaUpgrade( + upgradeRequest(draft, workspace.snapshot().version(), mappings)); + + Assertions.assertEquals(200, response.statusCode(), response.body()); + Assertions.assertTrue(proxy.getJson("items/" + id).body().contains("\"name\":\"Task A\"")); + Assertions.assertEquals(404, proxy.getJson("todos").statusCode()); } @Test public void manualRelationshipMappingPreservesRenamedRelationshipEdges() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); - DynamicThingifierApiProxy proxy = new DynamicThingifierApiProxy(workspace); - String projectId = createProject(proxy, "Project A"); - String todoId = createTodo(proxy, "Task A"); - Assertions.assertEquals( - 201, - proxy.postJson( - "projects/" + projectId + "/tasks", - "{\"id\":\"" + todoId + "\"}") - .statusCode()); - CrudUiController controller = new CrudUiController(workspace); - JsonObject draft = draftFromYaml("/models/project-tasks.yaml"); - JsonObject relationship = - draft.getAsJsonArray("relationships").get(0).getAsJsonObject(); - relationship.addProperty("name", "todos"); - relationship.getAsJsonObject("reverse").addProperty("name", "projectsof"); - JsonObject mappings = new JsonObject(); - JsonArray relationshipMappings = new JsonArray(); - JsonObject mapping = new JsonObject(); - mapping.addProperty("targetFromEntity", "project"); - mapping.addProperty("targetName", "todos"); - mapping.addProperty("sourceFromEntity", "project"); - mapping.addProperty("sourceName", "tasks"); - relationshipMappings.add(mapping); - mappings.add("relationshipMappings", relationshipMappings); - - UiHttpResponse response = - controller.applySchemaUpgrade( - upgradeRequest(draft, workspace.snapshot().version(), mappings)); - - Assertions.assertEquals(200, response.statusCode(), response.body()); - Assertions.assertTrue( - proxy.getJson("projects/" + projectId + "/todos").body().contains("Task A")); - Assertions.assertEquals( - 404, proxy.getJson("projects/" + projectId + "/tasks").statusCode()); - } + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + String projectId = createProject(proxy, "Project A"); + String todoId = createTodo(proxy, "Task A"); + Assertions.assertEquals( + 201, + proxy.postJson("projects/" + projectId + "/tasks", "{\"id\":\"" + todoId + "\"}") + .statusCode()); + JsonObject draft = draftFromYaml("/models/project-tasks.yaml"); + JsonObject relationship = draft.getAsJsonArray("relationships").get(0).getAsJsonObject(); + relationship.addProperty("name", "todos"); + relationship.getAsJsonObject("reverse").addProperty("name", "projectsof"); + JsonObject mappings = new JsonObject(); + JsonArray relationshipMappings = new JsonArray(); + JsonObject mapping = new JsonObject(); + mapping.addProperty("targetFromEntity", "project"); + mapping.addProperty("targetName", "todos"); + mapping.addProperty("sourceFromEntity", "project"); + mapping.addProperty("sourceName", "tasks"); + relationshipMappings.add(mapping); + mappings.add("relationshipMappings", relationshipMappings); + + UiHttpResponse response = + controller.applySchemaUpgrade( + upgradeRequest(draft, workspace.snapshot().version(), mappings)); + + Assertions.assertEquals(200, response.statusCode(), response.body()); + Assertions.assertTrue( + proxy.getJson("projects/" + projectId + "/todos").body().contains("Task A")); + Assertions.assertEquals( + 404, proxy.getJson("projects/" + projectId + "/tasks").statusCode()); } @Test public void typeCoercionsAreReported() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); - DynamicThingifierApiProxy proxy = new DynamicThingifierApiProxy(workspace); - createTodo(proxy, "123"); - CrudUiController controller = new CrudUiController(workspace); - JsonObject draft = draftFromYaml("/models/project-tasks.yaml"); - fieldNamed(entityNamed(draft, "todo"), "title").addProperty("type", "integer"); - - UiHttpResponse response = - controller.applySchemaUpgrade( - upgradeRequest(draft, workspace.snapshot().version(), null)); - JsonObject body = json(response); - - Assertions.assertEquals(200, response.statusCode(), response.body()); - Assertions.assertTrue(body.get("coercions").toString().contains("string to integer")); - Assertions.assertTrue(proxy.getJson("todos").body().contains("\"title\":123")); - } + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + createTodo(proxy, "123"); + JsonObject draft = draftFromYaml("/models/project-tasks.yaml"); + fieldNamed(entityNamed(draft, "todo"), "title").addProperty("type", "integer"); + + UiHttpResponse response = + controller.applySchemaUpgrade( + upgradeRequest(draft, workspace.snapshot().version(), null)); + JsonObject body = json(response); + + Assertions.assertEquals(200, response.statusCode(), response.body()); + Assertions.assertTrue(body.get("coercions").toString().contains("string to integer")); + Assertions.assertTrue(proxy.getJson("todos").body().contains("\"title\":123")); } @Test public void newFieldFallbacksAreReportedAsAssignmentsNotCoercions() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); - DynamicThingifierApiProxy proxy = new DynamicThingifierApiProxy(workspace); - createProject(proxy, "Smoke Project"); - CrudUiController controller = new CrudUiController(workspace); - JsonObject draft = draftFromYaml("/models/project-tasks.yaml"); - entityNamed(draft, "project") - .getAsJsonArray("fields") - .add(field("risk", "integer", null)); - - UiHttpResponse response = - controller.previewSchemaUpgrade( - upgradeRequest(draft, workspace.snapshot().version(), null)); - JsonObject body = json(response); - - Assertions.assertEquals(200, response.statusCode(), response.body()); - Assertions.assertTrue( - body.get("valueAssignments").toString().contains("Used target type fallback")); - Assertions.assertTrue(body.get("valueAssignments").toString().contains("risk")); - Assertions.assertEquals("[]", body.get("coercions").toString()); - } + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + createProject(proxy, "Smoke Project"); + JsonObject draft = draftFromYaml("/models/project-tasks.yaml"); + entityNamed(draft, "project").getAsJsonArray("fields").add(field("risk", "integer", null)); + + UiHttpResponse response = + controller.previewSchemaUpgrade( + upgradeRequest(draft, workspace.snapshot().version(), null)); + JsonObject body = json(response); + + Assertions.assertEquals(200, response.statusCode(), response.body()); + Assertions.assertTrue( + body.get("valueAssignments").toString().contains("Used target type fallback")); + Assertions.assertTrue(body.get("valueAssignments").toString().contains("risk")); + Assertions.assertEquals("[]", body.get("coercions").toString()); } @Test public void remainingValidationErrorsBlockApplyAndLeaveWorkspaceUnchanged() { - try (ActiveThingifierWorkspace workspace = - ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { - workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); - DynamicThingifierApiProxy proxy = new DynamicThingifierApiProxy(workspace); - createTodo(proxy, "Task A"); - CrudUiController controller = new CrudUiController(workspace); - JsonObject draft = draftFromYaml("/models/project-tasks.yaml"); - JsonObject requiredCode = field("code", "string", null); - requiredCode.addProperty("required", true); - JsonArray validations = new JsonArray(); - JsonObject notEmpty = new JsonObject(); - notEmpty.addProperty("type", "notEmpty"); - validations.add(notEmpty); - requiredCode.add("validations", validations); - entityNamed(draft, "todo").getAsJsonArray("fields").add(requiredCode); - long version = workspace.snapshot().version(); - - UiHttpResponse response = - controller.applySchemaUpgrade(upgradeRequest(draft, version, null)); - - Assertions.assertEquals(400, response.statusCode()); - Assertions.assertEquals(version, workspace.snapshot().version()); - Assertions.assertTrue(response.body().contains("Failed Validation")); - Assertions.assertTrue(proxy.getJson("todos").body().contains("Task A")); - } + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + createTodo(proxy, "Task A"); + JsonObject draft = draftFromYaml("/models/project-tasks.yaml"); + JsonObject requiredCode = field("code", "string", null); + requiredCode.addProperty("required", true); + JsonArray validations = new JsonArray(); + JsonObject notEmpty = new JsonObject(); + notEmpty.addProperty("type", "notEmpty"); + validations.add(notEmpty); + requiredCode.add("validations", validations); + entityNamed(draft, "todo").getAsJsonArray("fields").add(requiredCode); + long version = workspace.snapshot().version(); + + UiHttpResponse response = + controller.applySchemaUpgrade(upgradeRequest(draft, version, null)); + + Assertions.assertEquals(400, response.statusCode()); + Assertions.assertEquals(version, workspace.snapshot().version()); + Assertions.assertTrue(response.body().contains("Failed Validation")); + Assertions.assertTrue(proxy.getJson("todos").body().contains("Task A")); } private String createProject(final DynamicThingifierApiProxy proxy, final String title) { diff --git a/thingifier/pom.xml b/thingifier/pom.xml index 097a9c52..f739e9b5 100644 --- a/thingifier/pom.xml +++ b/thingifier/pom.xml @@ -73,6 +73,16 @@ gson ${gson-version} + + com.fasterxml.jackson.core + jackson-core + ${jackson-version} + + + io.github.vishwakarma + zjsonpatch + ${zjsonpatch-version} + diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/EntityPatchDocumentMapper.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/EntityPatchDocumentMapper.java new file mode 100644 index 00000000..c701e6e2 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/EntityPatchDocumentMapper.java @@ -0,0 +1,240 @@ +package uk.co.compendiumdev.thingifier.adapter.http.apihandlers; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.flipkart.zjsonpatch.JsonPatch; +import java.util.Map; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.InstanceRoute; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.ThingRoute; +import uk.co.compendiumdev.thingifier.api.ermodelconversion.JsonThing; +import uk.co.compendiumdev.thingifier.api.http.ThingifierRequestContext; +import uk.co.compendiumdev.thingifier.api.http.bodyparser.ApiBodyFields; +import uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; + +public final class EntityPatchDocumentMapper { + + private static final ObjectMapper JSON = new ObjectMapper(); + + private final ThingifierApiRuntime runtime; + private final ThingWriteRequestMapper writeMapper; + + public EntityPatchDocumentMapper(final ThingifierApiRuntime runtime) { + this.runtime = runtime; + this.writeMapper = new ThingWriteRequestMapper(runtime.schema()); + } + + public ThingWriteRequestMapping map( + final EntityPatchUpdateStyle style, + final ThingRoute route, + final String rawBody, + final ThingifierRequestContext context) { + if (!(route instanceof InstanceRoute)) { + return writeMapper.mapPatch(route, ApiBodyFields.empty()); + } + + InstanceRoute instanceRoute = (InstanceRoute) route; + if (style == EntityPatchUpdateStyle.PARTIAL_JSON_UPDATE) { + return mapPartialJsonUpdate(instanceRoute, rawBody); + } + if (style == EntityPatchUpdateStyle.JSON_MERGE_PATCH_RFC7396) { + return mapJsonMergePatch(instanceRoute, rawBody, context); + } + if (style == EntityPatchUpdateStyle.JSON_PATCH_RFC6902) { + return mapJsonPatch(instanceRoute, rawBody, context); + } + + return ThingWriteRequestMapping.error( + ApiMappingError.withMessage(400, "Unsupported PATCH update style")); + } + + private ThingWriteRequestMapping mapPartialJsonUpdate( + final InstanceRoute route, final String rawBody) { + ParseResult parsed = parseJsonObject(rawBody, true); + if (parsed.error != null) { + return parsed.error; + } + return writeMapper.mapPatch(route, parsed.bodyFields()); + } + + private ThingWriteRequestMapping mapJsonMergePatch( + final InstanceRoute route, + final String rawBody, + final ThingifierRequestContext context) { + EntityInstance instance = findInstance(route, context); + if (instance == null) { + return missingInstance(route); + } + + JsonNode patchDocument; + try { + patchDocument = JSON.readTree(rawBody); + } catch (JsonProcessingException e) { + return malformedPatch("Malformed JSON Merge Patch document"); + } + + if (patchDocument == null || !patchDocument.isObject()) { + return unprocessablePatch("JSON Merge Patch for entity resources must be an object"); + } + + JsonNode patchedDocument = applyMergePatch(jsonFor(instance), patchDocument); + return mapReplacement(route, patchedDocument); + } + + private ThingWriteRequestMapping mapJsonPatch( + final InstanceRoute route, + final String rawBody, + final ThingifierRequestContext context) { + EntityInstance instance = findInstance(route, context); + if (instance == null) { + return missingInstance(route); + } + + JsonNode patchDocument; + try { + patchDocument = JSON.readTree(rawBody); + } catch (JsonProcessingException e) { + return malformedPatch("Malformed JSON Patch document"); + } + + if (patchDocument == null || !patchDocument.isArray()) { + return malformedPatch("JSON Patch document must be an array of operations"); + } + + JsonNode patchedDocument; + try { + patchedDocument = JsonPatch.apply(patchDocument, jsonFor(instance)); + } catch (RuntimeException e) { + return conflictingPatch("JSON Patch could not be applied"); + } + + return mapReplacement(route, patchedDocument); + } + + private JsonNode applyMergePatch(final JsonNode target, final JsonNode patch) { + if (!patch.isObject()) { + return patch; + } + + ObjectNode result = + target != null && target.isObject() + ? ((ObjectNode) target).deepCopy() + : JSON.createObjectNode(); + patch.fields() + .forEachRemaining( + entry -> { + if (entry.getValue().isNull()) { + result.remove(entry.getKey()); + } else { + result.set( + entry.getKey(), + applyMergePatch( + result.get(entry.getKey()), entry.getValue())); + } + }); + return result; + } + + private ThingWriteRequestMapping mapReplacement( + final InstanceRoute route, final JsonNode patchedDocument) { + if (patchedDocument == null || !patchedDocument.isObject()) { + return unprocessablePatch("PATCH result for entity resources must be an object"); + } + + return writeMapper.mapPatchReplacingFields( + route, ApiBodyFields.fromMap(JSON.convertValue(patchedDocument, Map.class))); + } + + private JsonNode jsonFor(final EntityInstance instance) { + try { + return JSON.readTree( + new JsonThing(runtime.apiConfig().jsonOutput()) + .asJsonObject(instance) + .toString()); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Could not render entity instance as JSON", e); + } + } + + private EntityInstance findInstance( + final InstanceRoute route, final ThingifierRequestContext context) { + EntityDefinition entity = + runtime.schema().definitionWithSingularOrPluralNamed(route.entity().name()); + if (entity == null) { + return null; + } + return context.store().entityQueries().findByQueryIdentifier(entity, route.identifier()); + } + + private ThingWriteRequestMapping missingInstance(final InstanceRoute route) { + return ThingWriteRequestMapping.error( + ApiMappingError.withMessage( + 404, + String.format( + "No such %s entity instance with %s == %s found", + route.entity().name(), + route.entity().primaryKeyFieldName(), + route.identifier()))); + } + + private ParseResult parseJsonObject(final String rawBody, final boolean allowEmpty) { + String body = rawBody == null ? "" : rawBody.trim(); + if (body.isEmpty()) { + return allowEmpty + ? ParseResult.bodyFields(ApiBodyFields.empty()) + : ParseResult.error(malformedPatch("Malformed JSON document")); + } + + JsonNode document; + try { + document = JSON.readTree(body); + } catch (JsonProcessingException e) { + return ParseResult.error(malformedPatch("Malformed JSON document")); + } + + if (document == null || !document.isObject()) { + return ParseResult.error( + malformedPatch("PATCH partial JSON update document must be an object")); + } + + return ParseResult.bodyFields( + ApiBodyFields.fromMap(JSON.convertValue(document, Map.class))); + } + + private ThingWriteRequestMapping malformedPatch(final String message) { + return ThingWriteRequestMapping.error(ApiMappingError.withMessage(400, message)); + } + + private ThingWriteRequestMapping unprocessablePatch(final String message) { + return ThingWriteRequestMapping.error(ApiMappingError.withMessage(422, message)); + } + + private ThingWriteRequestMapping conflictingPatch(final String message) { + return ThingWriteRequestMapping.error(ApiMappingError.withMessage(409, message)); + } + + private static final class ParseResult { + private final ApiBodyFields bodyFields; + private final ThingWriteRequestMapping error; + + private ParseResult(final ApiBodyFields bodyFields, final ThingWriteRequestMapping error) { + this.bodyFields = bodyFields; + this.error = error; + } + + static ParseResult bodyFields(final ApiBodyFields bodyFields) { + return new ParseResult(bodyFields, null); + } + + static ParseResult error(final ThingWriteRequestMapping error) { + return new ParseResult(ApiBodyFields.empty(), error); + } + + ApiBodyFields bodyFields() { + return bodyFields; + } + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ThingBodyCommandMapper.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ThingBodyCommandMapper.java index 42c28d14..d78abab7 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ThingBodyCommandMapper.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ThingBodyCommandMapper.java @@ -64,6 +64,20 @@ public ThingWriteRequestMapping mapAmend( final EntityTypeRef entity, final String identifier, final boolean replaceExistingFieldsAndRelationships) { + return mapAmend( + bodyFields, + entity, + identifier, + replaceExistingFieldsAndRelationships, + replaceExistingFieldsAndRelationships); + } + + public ThingWriteRequestMapping mapAmend( + final ApiBodyFields bodyFields, + final EntityTypeRef entity, + final String identifier, + final boolean replaceExistingFields, + final boolean replaceExistingRelationships) { RelationshipBodyCommands relationships = parseRelationships(bodyFields, entity); if (!relationships.validationReport().isValid()) { return ThingWriteRequestMapping.error( @@ -77,7 +91,8 @@ public ThingWriteRequestMapping mapAmend( identifier, fieldValuesExcludingRelationships(bodyFields, relationships), bodyFieldValues(bodyFields), - replaceExistingFieldsAndRelationships, + replaceExistingFields, + replaceExistingRelationships, relationships.references())); } 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 d87351e5..3ea0963b 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 @@ -98,6 +98,17 @@ public ThingWriteRequestMapping mapPatch( ApiMappingError.withMessage(400, "Your request was not understood")); } + public ThingWriteRequestMapping mapPatchReplacingFields( + final ThingRoute route, final ApiBodyFields bodyFields) { + if (route instanceof InstanceRoute) { + InstanceRoute instance = (InstanceRoute) route; + return mapPatchToInstanceReplacingFields(instance, bodyFields); + } + + return ThingWriteRequestMapping.error( + ApiMappingError.withMessage(400, "Your request was not understood")); + } + public ThingWriteRequestMapping mapDelete(final ThingRoute route) { if (route instanceof CollectionRoute) { return ThingWriteRequestMapping.error( @@ -152,6 +163,29 @@ private ThingWriteRequestMapping mapPostToInstance( route.entity().name()))); } + private ThingWriteRequestMapping mapPatchToInstanceReplacingFields( + final InstanceRoute route, final ApiBodyFields bodyFields) { + if (route.entity().hasPrimaryKeyField()) { + ThingWriteRequestMapping mapping = + bodyCommandMapper.mapAmend( + bodyFields, route.entity(), route.identifier(), true, false); + return mapping.withRouteDisplay( + ApiRouteDisplay.missingInstanceMessage( + String.format( + "No such %s entity instance with %s == %s found", + route.entity().name(), + route.entity().primaryKeyFieldName(), + route.identifier()))); + } + + return ThingWriteRequestMapping.error( + ApiMappingError.withMessage( + 404, + String.format( + "Entity %s does not have a primary key defined", + route.entity().name()))); + } + private ThingWriteRequestMapping mapPostToRelationship( final RelationshipCollectionRoute route, final ApiBodyFields bodyFields) { EntityTypeRef childEntity = firstRelationshipTarget(route); 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 53dd5b2e..0286afb6 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,6 +12,7 @@ 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.EntityPatchUpdateStyle; import uk.co.compendiumdev.thingifier.apiconfig.EntityWriteOperation; import uk.co.compendiumdev.thingifier.apiconfig.RelationshipWriteOperation; import uk.co.compendiumdev.thingifier.application.schema.RelationshipSpec; @@ -49,6 +50,10 @@ private ApiResponse rejectEntityWriteIfNotAllowed( final RoutingVerb verb, final ThingRoute route, final ThingifierRequestContext context) { + if (verb == RoutingVerb.PATCH) { + return rejectEntityPatchIfNotAllowed(route, context); + } + EntityWriteOperation operation = entityOperationFor(verb, route, context); if (operation == null) { return null; @@ -62,6 +67,21 @@ private ApiResponse rejectEntityWriteIfNotAllowed( return methodNotAllowed(allowHeaderFor(route, context, verb, operation)); } + private ApiResponse rejectEntityPatchIfNotAllowed( + final ThingRoute route, final ThingifierRequestContext context) { + if (route instanceof CollectionRoute) { + return methodNotAllowed( + allowHeaderFor(route, context, RoutingVerb.PATCH, EntityWriteOperation.UPDATE)); + } + + if (route instanceof InstanceRoute && entityPatchUpdateStylesFor(route).isEmpty()) { + return methodNotAllowed( + allowHeaderFor(route, context, RoutingVerb.PATCH, EntityWriteOperation.UPDATE)); + } + + return null; + } + private ApiResponse rejectRelationshipWriteIfNotAllowed( final RoutingVerb verb, final ThingRoute route, final ApiBodyFields bodyFields) { RelationshipWriteOperation operation = relationshipOperationFor(verb, route, bodyFields); @@ -162,6 +182,13 @@ private Set entityOperationsFor( .orElse(runtime.apiConfig().writeMethods().entities().operationsFor(verb)); } + public Set entityPatchUpdateStylesFor(final ThingRoute route) { + return runtime.apiSpec() + .entityPatchUpdateStylesFor( + route.originalPath(), runtime.apiConfig().getApiEndPointPrefix()) + .orElse(runtime.apiConfig().writeMethods().entities().patchUpdateStyles()); + } + private Set relationshipOperationsFor( final RoutingVerb verb, final ThingRoute route) { return runtime.apiSpec() @@ -197,8 +224,7 @@ private String allowHeaderFor( RoutingVerb.PUT, route, context, blockedVerb, blockedOperation)) { allowed.add("PUT"); } - if (entityOperationsFor(RoutingVerb.PATCH, route) - .contains(EntityWriteOperation.UPDATE)) { + if (!entityPatchUpdateStylesFor(route).isEmpty()) { allowed.add("PATCH"); } allowed.add("DELETE"); diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/httpserver/ThingifierHttpApiRoutings.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/httpserver/ThingifierHttpApiRoutings.java index cb984d6a..544e1d11 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/httpserver/ThingifierHttpApiRoutings.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/httpserver/ThingifierHttpApiRoutings.java @@ -238,6 +238,17 @@ public ThingifierHttpApiRoutings( applyStaticResponse(defn, response); return ""; }); + } else { + patch( + defn.url(), + (request, response) -> { + final InternalHttpRequest theRequest = + internalRequestFrom(request); + final InternalHttpResponse theResponse = + apiBridge.patch(theRequest); + return InternalHttpResponseToHttpServer.convert( + theResponse, response); + }); } break; case PUT: @@ -376,6 +387,11 @@ private void applyStaticResponse( if (!defn.header().isEmpty()) { response.header(defn.header(), defn.headerValue()); } + if (defn.hasResponseHeaders()) { + for (String headerName : defn.getResponseHeaderNames()) { + response.header(headerName, defn.getResponseHeaderValue(headerName)); + } + } if (defn.headerValue().contains("QUERY")) { response.header( ThingifierHttpApi.ACCEPT_QUERY_HEADER, ThingifierHttpApi.QUERY_CONTENT_TYPE); diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/internalhttp/conversion/ThingifierHttpApiBridge.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/internalhttp/conversion/ThingifierHttpApiBridge.java index 52416456..18f37b30 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/internalhttp/conversion/ThingifierHttpApiBridge.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/internalhttp/conversion/ThingifierHttpApiBridge.java @@ -50,6 +50,10 @@ public InternalHttpResponse put(final InternalHttpRequest theRequest) { return toInternalResponse(thingifierHttpApi.put(toHttpApiRequest(theRequest))); } + public InternalHttpResponse patch(final InternalHttpRequest theRequest) { + return toInternalResponse(thingifierHttpApi.patch(toHttpApiRequest(theRequest))); + } + public InternalHttpResponse queryRequest(final InternalHttpRequest theRequest) { return toInternalResponse(thingifierHttpApi.queryRequest(toHttpApiRequest(theRequest))); } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ThingifierRestAPIHandler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ThingifierRestAPIHandler.java index c787a600..9162d160 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ThingifierRestAPIHandler.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ThingifierRestAPIHandler.java @@ -125,19 +125,25 @@ public ApiResponse put( public ApiResponse patch(final String url, final BodyParser args, HttpHeadersBlock headers) { ThingifierRequestContext context = contextFrom(headers); - return patch(url, args.bodyFields(), context); + return patch(url, args.rawBody(), headers, context); + } + + public ApiResponse patch(final String url, final String body, HttpHeadersBlock headers) { + ThingifierRequestContext context = contextFrom(headers); + return patch(url, body, headers, context); } public ApiResponse patch(final ApiRequestEnvelope request) { ThingifierRequestContext context = contextFrom(request.headers()); - return patch(request.path(), request.bodyFields(), context); + return patch(request.path(), request.body(), request.headers(), context); } public ApiResponse patch( final String url, - final ApiBodyFields bodyFields, + final String body, + final HttpHeadersBlock headers, final ThingifierRequestContext context) { - return withRepository(patch.handle(url, bodyFields, context), context); + return withRepository(patch.handle(url, body, headers, context), context); } private ThingifierRequestContext contextFrom(final HttpHeadersBlock headers) { diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/RoutingDefinition.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/RoutingDefinition.java index d8abb9d3..1bd5b32c 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/RoutingDefinition.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/RoutingDefinition.java @@ -19,8 +19,10 @@ public class RoutingDefinition { private List possibleStatusResponses; private HashMap returnPayload; private String requestPayload; + private List requestContentTypes; private List requestUrlParams; private HashMap customHeaders; + private HashMap responseHeaders; private boolean usesBasicAuth = false; private boolean usesBearerAuth = false; private boolean hiddenFromDocumentation = false; @@ -46,7 +48,9 @@ public RoutingDefinition( requestUrlParams = new ArrayList<>(); returnPayload = new HashMap<>(); requestPayload = null; + requestContentTypes = new ArrayList<>(); customHeaders = new HashMap<>(); + responseHeaders = new HashMap<>(); requestEntityViewName = null; responseEntityViewNames = new HashMap<>(); } @@ -100,6 +104,25 @@ public RoutingDefinition replaceHeader(final ResponseHeader header) { return this; } + public RoutingDefinition addResponseHeader(final String headerName, final String value) { + if (headerName != null) { + responseHeaders.put(headerName, value == null ? "" : value); + } + return this; + } + + public boolean hasResponseHeaders() { + return !responseHeaders.isEmpty(); + } + + public Collection getResponseHeaderNames() { + return responseHeaders.keySet(); + } + + public String getResponseHeaderValue(final String headerName) { + return responseHeaders.get(headerName); + } + public String getDocumentation() { return this.documentation; } @@ -168,6 +191,18 @@ public RoutingDefinition requestPayload(String payloadName) { return this; } + public RoutingDefinition requestContentTypes(final String... contentTypes) { + requestContentTypes.clear(); + if (contentTypes != null) { + for (String contentType : contentTypes) { + if (contentType != null && !contentType.trim().isEmpty()) { + requestContentTypes.add(contentType.trim()); + } + } + } + return this; + } + public Boolean hasRequestPayload() { return requestPayload != null; } @@ -176,6 +211,13 @@ public String getRequestPayload() { return requestPayload; } + public List getRequestContentTypes() { + if (requestContentTypes.isEmpty()) { + return List.of("application/json", "application/xml"); + } + return new ArrayList<>(requestContentTypes); + } + public RoutingDefinition requestEntityView(final String viewName) { requestEntityViewName = viewName; requestPayload("create_" + viewName); 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 8afd5ddf..63df248b 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,6 +10,7 @@ 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.EntityPatchUpdateStyle; import uk.co.compendiumdev.thingifier.apiconfig.EntityWriteOperation; import uk.co.compendiumdev.thingifier.apiconfig.RelationshipWriteOperation; @@ -29,6 +30,7 @@ public void applyTo(final ApiRoutingDefinition routingDefinition, final String a new ThingRouteMapper(schema).map(removePrefix(route.url(), apiPathPrefix)); applyTo(route, thingRoute, apiPathPrefix); } + updateAcceptPatchHeaders(routingDefinition, apiPathPrefix); } private void applyTo( @@ -77,13 +79,17 @@ private void applyEntityPolicy( } } if (route.verb() == RoutingVerb.PATCH && thingRoute instanceof InstanceRoute) { - setEntityRouteSupport( - route, - thingRoute, - apiPathPrefix, - EntityWriteOperation.UPDATE, - ((InstanceRoute) thingRoute).entity().name(), - false); + Set styles = + entityPatchUpdateStylesFor(thingRoute, apiPathPrefix); + if (styles.isEmpty()) { + methodNotAllowed(route); + } else { + returnedEntityWriteRoute( + route, ((InstanceRoute) thingRoute).entity().name(), false); + ensureStatus(route, 400); + ensureStatus(route, 415); + route.requestContentTypes(mediaTypesFor(styles)); + } } } @@ -172,6 +178,14 @@ private Set entityOperationsFor( .orElse(thingifier.apiConfig().writeMethods().entities().operationsFor(verb)); } + private Set entityPatchUpdateStylesFor( + final ThingRoute route, final String apiPathPrefix) { + return thingifier + .apiSpec() + .entityPatchUpdateStylesFor(route.originalPath(), apiPathPrefix) + .orElse(thingifier.apiConfig().writeMethods().entities().patchUpdateStyles()); + } + private Set relationshipOperationsFor( final RoutingVerb verb, final ThingRoute route, final String apiPathPrefix) { return thingifier @@ -193,6 +207,35 @@ private void ensureStatus(final RoutingDefinition route, final int statusCode) { route.addPossibleStatus(RoutingStatus.returnValue(statusCode)); } + private void updateAcceptPatchHeaders( + final ApiRoutingDefinition routingDefinition, final String apiPathPrefix) { + for (RoutingDefinition route : routingDefinition.definitions()) { + if (route.verb() != RoutingVerb.OPTIONS) { + continue; + } + + ThingRoute thingRoute = + new ThingRouteMapper(schema).map(removePrefix(route.url(), apiPathPrefix)); + if (!(thingRoute instanceof InstanceRoute)) { + continue; + } + + Set styles = + entityPatchUpdateStylesFor(thingRoute, apiPathPrefix); + if (!styles.isEmpty()) { + route.addResponseHeader( + "Accept-Patch", EntityPatchUpdateStyle.acceptPatchHeaderValue(styles)); + } + } + } + + private String[] mediaTypesFor(final Set styles) { + return styles.stream() + .sorted() + .map(EntityPatchUpdateStyle::mediaType) + .toArray(String[]::new); + } + private String removePrefix(final String path, final String apiPathPrefix) { final String normalizedPath = normalize(path); final String normalizedPrefix = normalize(apiPathPrefix); diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ApiRequestEnvelope.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ApiRequestEnvelope.java index 4150fe0c..075caaac 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ApiRequestEnvelope.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ApiRequestEnvelope.java @@ -13,18 +13,21 @@ public final class ApiRequestEnvelope { private final QueryFilterParams queryParams; private final HttpHeadersBlock headers; private final ApiBodyFields bodyFields; + private final String body; private ApiRequestEnvelope( final ThingifierHttpApi.HttpVerb verb, final String path, final QueryFilterParams queryParams, final HttpHeadersBlock headers, - final ApiBodyFields bodyFields) { + final ApiBodyFields bodyFields, + final String body) { this.verb = verb; this.path = path; this.queryParams = queryParams; this.headers = headers; this.bodyFields = bodyFields; + this.body = body == null ? "" : body; } public static ApiRequestEnvelope from( @@ -32,9 +35,7 @@ public static ApiRequestEnvelope from( final ThingifierHttpApi.HttpVerb verb, final List thingNames) { ApiBodyFields bodyFields = ApiBodyFields.empty(); - if (verb == ThingifierHttpApi.HttpVerb.POST - || verb == ThingifierHttpApi.HttpVerb.PUT - || verb == ThingifierHttpApi.HttpVerb.PATCH) { + if (verb == ThingifierHttpApi.HttpVerb.POST || verb == ThingifierHttpApi.HttpVerb.PUT) { bodyFields = new BodyParser(request, thingNames).bodyFields(); } QueryFilterParams queryParams = request.getFilterableQueryParams(); @@ -42,7 +43,12 @@ public static ApiRequestEnvelope from( queryParams = queryContentAndUriQueryParams(request); } return new ApiRequestEnvelope( - verb, request.getPath(), queryParams, request.getHeaders(), bodyFields); + verb, + request.getPath(), + queryParams, + request.getHeaders(), + bodyFields, + request.getBody()); } private static QueryFilterParams queryContentAndUriQueryParams(final HttpApiRequest request) { @@ -71,4 +77,8 @@ public HttpHeadersBlock headers() { public ApiBodyFields bodyFields() { return bodyFields; } + + public String body() { + return body; + } } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/HttpApiRequestValidator.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/HttpApiRequestValidator.java index 8e5748f8..705b16ee 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/HttpApiRequestValidator.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/HttpApiRequestValidator.java @@ -50,9 +50,7 @@ public boolean validateSyntax( if (apiResponse == null) { // only validate content if it contains content - if (verb == ThingifierHttpApi.HttpVerb.POST - || verb == ThingifierHttpApi.HttpVerb.PUT - || verb == ThingifierHttpApi.HttpVerb.PATCH) { + if (verb == ThingifierHttpApi.HttpVerb.POST || verb == ThingifierHttpApi.HttpVerb.PUT) { apiResponse = new ContentTypeHeaderValidator(this.apiConfig) diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierHttpApi.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierHttpApi.java index 4af5a98a..ef5e3825 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierHttpApi.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierHttpApi.java @@ -222,7 +222,7 @@ public ApiResponse routeAndProcessRequest(final HttpApiRequest request, HttpVerb private HttpApiResponse validateEntityViewInput( final HttpApiRequest request, final HttpVerb verb) { - if (verb != HttpVerb.POST && verb != HttpVerb.PUT && verb != HttpVerb.PATCH) { + if (verb != HttpVerb.POST && verb != HttpVerb.PUT) { return null; } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/bodyparser/BodyParser.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/bodyparser/BodyParser.java index 102d80e7..ec57a205 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/bodyparser/BodyParser.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/bodyparser/BodyParser.java @@ -117,6 +117,10 @@ public Map getMap() { return args; } + public String rawBody() { + return request.getBody(); + } + public ApiBodyFields bodyFields() { return ApiBodyFields.fromMap(getMap()); } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/headers/headerparser/ContentTypeHeaderParser.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/headers/headerparser/ContentTypeHeaderParser.java index 00c59145..c8eb6398 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/headers/headerparser/ContentTypeHeaderParser.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/headers/headerparser/ContentTypeHeaderParser.java @@ -13,15 +13,23 @@ public ContentTypeHeaderParser(final String header) { public boolean isXML() { // text/xml in standard https://datatracker.ietf.org/doc/html/rfc3023 - return header.contains("application/xml") || header.contains("text/xml"); + return isMediaType("application/xml") || isMediaType("text/xml"); } public boolean isJSON() { - return header.contains("application/json"); + return isMediaType("application/json"); + } + + public boolean isJsonMergePatch() { + return isMediaType("application/merge-patch+json"); + } + + public boolean isJsonPatch() { + return isMediaType("application/json-patch+json"); } public boolean isFormUrlEncoded() { - return header.contains("application/x-www-form-urlencoded"); + return isMediaType("application/x-www-form-urlencoded"); } public boolean isMissing() { @@ -29,6 +37,18 @@ public boolean isMissing() { } public boolean isText() { - return header.contains("text/"); + return mediaType().startsWith("text/"); + } + + public boolean isMediaType(final String mediaType) { + return mediaType().equalsIgnoreCase(mediaType); + } + + public String mediaType() { + int separatorIndex = header.indexOf(";"); + if (separatorIndex == -1) { + return header; + } + return header.substring(0, separatorIndex).trim(); } } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPatchHandler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPatchHandler.java index 2787cea7..a48bf6ce 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPatchHandler.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPatchHandler.java @@ -1,20 +1,23 @@ package uk.co.compendiumdev.thingifier.api.restapihandlers; +import java.util.Optional; +import java.util.Set; import uk.co.compendiumdev.thingifier.Thingifier; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.DefaultThingifierApiRuntime; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.EntityPatchDocumentMapper; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.ThingCommandResultApiMapper; -import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.ThingWriteRequestMapper; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.ThingWriteRequestMapping; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.ThingifierApiRuntime; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.WriteMethodPolicy; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.InstanceRoute; 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.docgen.RoutingVerb; import uk.co.compendiumdev.thingifier.api.http.ThingifierRequestContext; -import uk.co.compendiumdev.thingifier.api.http.bodyparser.ApiBodyFields; import uk.co.compendiumdev.thingifier.api.http.bodyparser.BodyParser; import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock; import uk.co.compendiumdev.thingifier.api.response.ApiResponse; +import uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle; import uk.co.compendiumdev.thingifier.application.ThingCommandResult; public class RestApiPatchHandler { @@ -30,28 +33,47 @@ public RestApiPatchHandler(final ThingifierApiRuntime runtime) { public ApiResponse handle( final String url, final BodyParser args, final HttpHeadersBlock requestHeaders) { - return handle(url, args.bodyFields(), runtime.contextFrom(requestHeaders)); + return handle(url, args.rawBody(), requestHeaders, runtime.contextFrom(requestHeaders)); } public ApiResponse handle( final String url, final BodyParser args, final ThingifierRequestContext context) { - return handle(url, args.bodyFields(), context); + return handle(url, args.rawBody(), new HttpHeadersBlock(), context); } public ApiResponse handle( final String url, - final ApiBodyFields bodyFields, + final String rawBody, + final HttpHeadersBlock requestHeaders, final ThingifierRequestContext context) { ThingRoute route = new ThingRouteMapper(runtime.schema()).map(url); + WriteMethodPolicy policy = new WriteMethodPolicy(runtime); ApiResponse policyResponse = - new WriteMethodPolicy(runtime) - .rejectIfNotAllowed(RoutingVerb.PATCH, route, bodyFields, context); + policy.rejectIfNotAllowed( + RoutingVerb.PATCH, + route, + uk.co.compendiumdev.thingifier.api.http.bodyparser.ApiBodyFields.empty(), + context); if (policyResponse != null) { return policyResponse; } + Optional style = + EntityPatchUpdateStyle.fromContentType(requestHeaders.get("Content-Type")); + if (route instanceof InstanceRoute) { + Set allowedStyles = policy.entityPatchUpdateStylesFor(route); + if (style.isEmpty() || !allowedStyles.contains(style.get())) { + return unsupportedPatchContentType(allowedStyles); + } + } + ThingWriteRequestMapping mapping = - new ThingWriteRequestMapper(runtime.schema()).mapPatch(route, bodyFields); + new EntityPatchDocumentMapper(runtime) + .map( + style.orElse(EntityPatchUpdateStyle.PARTIAL_JSON_UPDATE), + route, + rawBody, + context); ThingCommandResultApiMapper apiMapper = new ThingCommandResultApiMapper(runtime.apiConfig()); if (mapping.isError()) { @@ -61,4 +83,12 @@ public ApiResponse handle( ThingCommandResult result = runtime.commandService(context).execute(mapping.getCommand()); return apiMapper.map(mapping, result); } + + private ApiResponse unsupportedPatchContentType( + final Set allowedStyles) { + return ApiResponse.error(415, "Unsupported PATCH Content Type") + .setHeader( + "Accept-Patch", + EntityPatchUpdateStyle.acceptPatchHeaderValue(allowedStyles)); + } } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiRouteRule.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiRouteRule.java index d466664a..d3e030c3 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiRouteRule.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiRouteRule.java @@ -7,6 +7,7 @@ import java.util.Set; import uk.co.compendiumdev.thingifier.api.docgen.RoutingDefinition; import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; +import uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle; import uk.co.compendiumdev.thingifier.apiconfig.EntityWriteOperation; import uk.co.compendiumdev.thingifier.apiconfig.RelationshipWriteOperation; @@ -24,6 +25,7 @@ public final class ThingifierApiRouteRule { private String defaultEntityView; private Map responseEntityViews; private EnumSet entityWriteOperations; + private EnumSet entityPatchUpdateStyles; private EnumSet relationshipWriteOperations; ThingifierApiRouteRule(final RoutingVerb verb, final String pathPattern) { @@ -39,6 +41,7 @@ public final class ThingifierApiRouteRule { this.defaultEntityView = null; this.responseEntityViews = new HashMap<>(); this.entityWriteOperations = null; + this.entityPatchUpdateStyles = null; this.relationshipWriteOperations = null; } @@ -111,6 +114,11 @@ public ThingifierApiRouteRule entityCan(final EntityWriteOperation... operations return entityWriteOperations(operations); } + public ThingifierApiRouteRule entityPatchCan(final EntityPatchUpdateStyle... updateStyles) { + this.entityPatchUpdateStyles = patchStyles(updateStyles); + return this; + } + public ThingifierApiRouteRule relationshipWriteOperations( final RelationshipWriteOperation... operations) { this.relationshipWriteOperations = relationshipOperations(operations); @@ -156,6 +164,17 @@ public Set entityWriteOperations() { return Collections.unmodifiableSet(EnumSet.copyOf(entityWriteOperations)); } + public boolean hasEntityPatchUpdateStyles() { + return entityPatchUpdateStyles != null; + } + + public Set entityPatchUpdateStyles() { + if (entityPatchUpdateStyles == null || entityPatchUpdateStyles.isEmpty()) { + return Set.of(); + } + return Collections.unmodifiableSet(EnumSet.copyOf(entityPatchUpdateStyles)); + } + public boolean hasRelationshipWriteOperations() { return relationshipWriteOperations != null; } @@ -217,6 +236,14 @@ private EnumSet entityOperations( return selected; } + private EnumSet patchStyles(final EntityPatchUpdateStyle... styles) { + EnumSet selected = EnumSet.noneOf(EntityPatchUpdateStyle.class); + if (styles != null) { + Collections.addAll(selected, styles); + } + return selected; + } + private EnumSet relationshipOperations( final RelationshipWriteOperation... operations) { EnumSet selected = 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 377ab761..7b863a14 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 @@ -10,6 +10,7 @@ import uk.co.compendiumdev.thingifier.api.docgen.ApiRoutingDefinition; import uk.co.compendiumdev.thingifier.api.docgen.RoutingDefinition; import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; +import uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle; import uk.co.compendiumdev.thingifier.apiconfig.EntityWriteOperation; import uk.co.compendiumdev.thingifier.apiconfig.RelationshipWriteOperation; @@ -17,11 +18,13 @@ public final class ThingifierApiSpec { private final List routeRules; private final List entityWritePolicyRules; + private final List entityPatchPolicyRules; private final List relationshipWritePolicyRules; public ThingifierApiSpec() { routeRules = new ArrayList<>(); entityWritePolicyRules = new ArrayList<>(); + entityPatchPolicyRules = new ArrayList<>(); relationshipWritePolicyRules = new ArrayList<>(); } @@ -70,8 +73,8 @@ public ThingifierApiSpec entityPutCan( } public ThingifierApiSpec entityPatchCan( - final String entityPath, final EntityWriteOperation... operations) { - configureEntityWritePolicy(RoutingVerb.PATCH, entityPath, operations); + final String entityPath, final EntityPatchUpdateStyle... updateStyles) { + configureEntityPatchPolicy(entityPath, updateStyles); return this; } @@ -143,6 +146,21 @@ public Optional> entityWriteOperationsFor( .findFirst(); } + public Optional> entityPatchUpdateStylesFor( + final String path, final String apiPathPrefix) { + Optional routeRule = + ruleFor(RoutingVerb.PATCH, path, apiPathPrefix) + .filter(ThingifierApiRouteRule::hasEntityPatchUpdateStyles); + if (routeRule.isPresent()) { + return Optional.of(routeRule.get().entityPatchUpdateStyles()); + } + + return entityPatchPolicyRules.stream() + .filter(rule -> pathsMatch(rule.pathPattern(), path, apiPathPrefix)) + .map(EntityPatchPolicyRule::updateStyles) + .findFirst(); + } + public Optional> relationshipWriteOperationsFor( final RoutingVerb verb, final String path, final String apiPathPrefix) { Optional routeRule = @@ -203,6 +221,13 @@ private void configureEntityWritePolicy( new EntityWritePolicyRule(verb, instancePath, entityOperations(operations))); } + private void configureEntityPatchPolicy( + final String entityPath, final EntityPatchUpdateStyle... updateStyles) { + final String instancePath = "/" + normalize(entityPath) + "/{id}"; + entityPatchPolicyRules.add( + new EntityPatchPolicyRule(instancePath, entityPatchStyles(updateStyles))); + } + private void configureRelationshipWritePolicy( final RoutingVerb verb, final String parentEntityPath, @@ -227,6 +252,18 @@ private Set entityOperations(final EntityWriteOperation... return Collections.unmodifiableSet(EnumSet.copyOf(selected)); } + private Set entityPatchStyles( + final EntityPatchUpdateStyle... updateStyles) { + EnumSet selected = EnumSet.noneOf(EntityPatchUpdateStyle.class); + if (updateStyles != null) { + Collections.addAll(selected, updateStyles); + } + if (selected.isEmpty()) { + return Set.of(); + } + return Collections.unmodifiableSet(EnumSet.copyOf(selected)); + } + private Set relationshipOperations( final RelationshipWriteOperation... operations) { EnumSet selected = @@ -333,6 +370,25 @@ Set operations() { } } + private static final class EntityPatchPolicyRule { + private final String pathPattern; + private final Set updateStyles; + + EntityPatchPolicyRule( + final String pathPattern, final Set updateStyles) { + this.pathPattern = pathPattern; + this.updateStyles = updateStyles; + } + + String pathPattern() { + return pathPattern; + } + + Set updateStyles() { + return updateStyles; + } + } + private static final class RelationshipWritePolicyRule { private final RoutingVerb verb; private final String pathPattern; diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/EntityPatchUpdateStyle.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/EntityPatchUpdateStyle.java new file mode 100644 index 00000000..94200e79 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/EntityPatchUpdateStyle.java @@ -0,0 +1,42 @@ +package uk.co.compendiumdev.thingifier.apiconfig; + +import java.util.Collection; +import java.util.Optional; +import java.util.stream.Collectors; +import uk.co.compendiumdev.thingifier.api.http.headers.headerparser.ContentTypeHeaderParser; + +public enum EntityPatchUpdateStyle { + PARTIAL_JSON_UPDATE("application/json"), + JSON_MERGE_PATCH_RFC7396("application/merge-patch+json"), + JSON_PATCH_RFC6902("application/json-patch+json"); + + private final String mediaType; + + EntityPatchUpdateStyle(final String mediaType) { + this.mediaType = mediaType; + } + + public String mediaType() { + return mediaType; + } + + public boolean matches(final String contentTypeHeader) { + return new ContentTypeHeaderParser(contentTypeHeader).isMediaType(mediaType); + } + + public static Optional fromContentType(final String contentTypeHeader) { + for (EntityPatchUpdateStyle style : values()) { + if (style.matches(contentTypeHeader)) { + return Optional.of(style); + } + } + return Optional.empty(); + } + + public static String acceptPatchHeaderValue(final Collection styles) { + return styles.stream() + .sorted() + .map(EntityPatchUpdateStyle::mediaType) + .collect(Collectors.joining(", ")); + } +} 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 b55af206..308c0902 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 @@ -9,12 +9,12 @@ public final class EntityWriteMethodConfig { private EnumSet postOperations; private EnumSet putOperations; - private EnumSet patchOperations; + private EnumSet patchUpdateStyles; public EntityWriteMethodConfig() { postOperations = operations(EntityWriteOperation.CREATE, EntityWriteOperation.UPDATE); putOperations = operations(EntityWriteOperation.CREATE, EntityWriteOperation.UPDATE); - patchOperations = operations(); + patchUpdateStyles = patchStyles(); } public EntityWriteMethodConfig postCan(final EntityWriteOperation... operations) { @@ -27,8 +27,8 @@ public EntityWriteMethodConfig putCan(final EntityWriteOperation... operations) return this; } - public EntityWriteMethodConfig patchCan(final EntityWriteOperation... operations) { - patchOperations = operations(operations); + public EntityWriteMethodConfig patchCan(final EntityPatchUpdateStyle... styles) { + patchUpdateStyles = patchStyles(styles); return this; } @@ -52,8 +52,8 @@ public Set putOperations() { return immutableCopyOf(putOperations); } - public Set patchOperations() { - return immutableCopyOf(patchOperations); + public Set patchUpdateStyles() { + return immutablePatchStyleCopyOf(patchUpdateStyles); } public Set operationsFor(final RoutingVerb verb) { @@ -63,16 +63,13 @@ public Set operationsFor(final RoutingVerb verb) { if (verb == RoutingVerb.PUT) { return putOperations(); } - if (verb == RoutingVerb.PATCH) { - return patchOperations(); - } return Set.of(); } public void setFrom(final EntityWriteMethodConfig source) { postOperations = copyOf(source.postOperations); putOperations = copyOf(source.putOperations); - patchOperations = copyOf(source.patchOperations); + patchUpdateStyles = patchStyleCopyOf(source.patchUpdateStyles); } static EnumSet operations(final EntityWriteOperation... operations) { @@ -83,6 +80,14 @@ static EnumSet operations(final EntityWriteOperation... op return selected; } + static EnumSet patchStyles(final EntityPatchUpdateStyle... styles) { + EnumSet selected = EnumSet.noneOf(EntityPatchUpdateStyle.class); + if (styles != null) { + Collections.addAll(selected, styles); + } + return selected; + } + private Set immutableCopyOf( final EnumSet operations) { if (operations.isEmpty()) { @@ -97,4 +102,20 @@ private EnumSet copyOf(final EnumSet } return EnumSet.copyOf(operations); } + + private Set immutablePatchStyleCopyOf( + final EnumSet styles) { + if (styles.isEmpty()) { + return Set.of(); + } + return Collections.unmodifiableSet(EnumSet.copyOf(styles)); + } + + private EnumSet patchStyleCopyOf( + final EnumSet styles) { + if (styles.isEmpty()) { + return patchStyles(); + } + return EnumSet.copyOf(styles); + } } 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 18890a8a..8c69ec41 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 @@ -60,7 +60,8 @@ ThingCommandResult handle(final AmendThingCommand command) { return amend( instance, draft, - command.shouldReplaceExistingFieldsAndRelationships(), + command.shouldReplaceExistingFields(), + command.shouldReplaceExistingRelationships(), command.getRelationships()); } catch (ThingStoreWriteException e) { throw e; @@ -86,7 +87,7 @@ ThingCommandResult handle(final ReplaceThingCommand command) { try { EntityInstanceDraft draft = new EntityInstanceDraftBuilder(instance).setFieldValuesFrom(fieldValues); - return amend(instance, draft, true, command.getRelationships()); + return amend(instance, draft, true, true, command.getRelationships()); } catch (ThingStoreWriteException e) { throw e; } catch (Exception e) { @@ -119,18 +120,22 @@ ThingCommandResult handle(final ReplaceThingCommand command) { private ThingCommandResult amend( final EntityInstance instance, final EntityInstanceDraft draft, - final boolean replaceExistingFieldsAndRelationships, + final boolean replaceExistingFields, + final boolean replaceExistingRelationships, final List relationshipReferences) { RelationshipSnapshot originalRelationships = RelationshipSnapshot.capture(store, instance); try { EntityInstance updated; - if (replaceExistingFieldsAndRelationships) { + if (replaceExistingFields) { updated = store.entities().replace(instance, draft); - originalRelationships.disconnectFrom(store, updated); } else { updated = store.entities().patch(instance, draft); } + if (replaceExistingRelationships) { + originalRelationships.disconnectFrom(store, updated); + } + ThingCommandResult relationshipResult = relationships.connectRelationshipReferences( updated, relationshipReferences, true, false); @@ -138,7 +143,7 @@ private ThingCommandResult amend( return relationshipResult; } - if (replaceExistingFieldsAndRelationships) { + if (replaceExistingRelationships) { originalRelationships.deleteFormerDependentsMadeInvalidBy(store, updated); } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/AmendThingCommand.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/AmendThingCommand.java index a8b97f4c..3349e312 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/AmendThingCommand.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/AmendThingCommand.java @@ -11,7 +11,8 @@ public final class AmendThingCommand implements ThingWriteCommand { private final String identifier; private final List fieldValues; private final List bodyFields; - private final boolean replaceExistingFieldsAndRelationships; + private final boolean replaceExistingFields; + private final boolean replaceExistingRelationships; private final List relationships; public AmendThingCommand( @@ -26,6 +27,7 @@ public AmendThingCommand( fieldValues, BodyFieldValue.fromNamedValues(fieldValues), replaceExistingFieldsAndRelationships, + replaceExistingFieldsAndRelationships, relationships); } @@ -36,11 +38,30 @@ public AmendThingCommand( final List bodyFields, final boolean replaceExistingFieldsAndRelationships, final List relationships) { + this( + entityName, + identifier, + fieldValues, + bodyFields, + replaceExistingFieldsAndRelationships, + replaceExistingFieldsAndRelationships, + relationships); + } + + public AmendThingCommand( + final String entityName, + final String identifier, + final List fieldValues, + final List bodyFields, + final boolean replaceExistingFields, + final boolean replaceExistingRelationships, + final List relationships) { this.entityName = entityName; this.identifier = identifier; this.fieldValues = Collections.unmodifiableList(new ArrayList<>(fieldValues)); this.bodyFields = Collections.unmodifiableList(new ArrayList<>(bodyFields)); - this.replaceExistingFieldsAndRelationships = replaceExistingFieldsAndRelationships; + this.replaceExistingFields = replaceExistingFields; + this.replaceExistingRelationships = replaceExistingRelationships; this.relationships = Collections.unmodifiableList(new ArrayList<>(relationships)); } @@ -61,7 +82,15 @@ public List getBodyFields() { } public boolean shouldReplaceExistingFieldsAndRelationships() { - return replaceExistingFieldsAndRelationships; + return replaceExistingFields && replaceExistingRelationships; + } + + public boolean shouldReplaceExistingFields() { + return replaceExistingFields; + } + + public boolean shouldReplaceExistingRelationships() { + return replaceExistingRelationships; } public List getRelationships() { diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/Swaggerizer.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/Swaggerizer.java index 533f93c5..981a7c3b 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/Swaggerizer.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/Swaggerizer.java @@ -3,6 +3,7 @@ import io.swagger.v3.core.util.Json; import io.swagger.v3.core.util.Json31; import io.swagger.v3.oas.models.*; +import io.swagger.v3.oas.models.headers.Header; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.media.*; import io.swagger.v3.oas.models.parameters.Parameter; @@ -163,14 +164,15 @@ && hasVisibleRouteForUrl(routes, route.url(), config)) { // situations if (!subroute.status().isReturnedFromCall()) { + ApiResponse response = + new ApiResponse() + .description(subroute.status().description()); + addRouteResponseHeaders(subroute, response); operation.setResponses( new ApiResponses() .addApiResponse( String.valueOf(subroute.status().value()), - new ApiResponse() - .description( - subroute.status() - .description()))); + response)); } else { final ApiResponses responses = new ApiResponses(); @@ -181,6 +183,7 @@ && hasVisibleRouteForUrl(routes, route.url(), config)) { ApiResponse response = new ApiResponse() .description(possibleStatus.description()); + addRouteResponseHeaders(subroute, response); if (subroute.hasReturnPayloadFor(possibleStatus.value())) { // assume that all payloads are setup as components if (routingDefinitions.hasObjectSchemaNamed( @@ -229,10 +232,11 @@ && hasVisibleRouteForUrl(routes, route.url(), config)) { schema.setSchema(object); object.set$ref(ref); - requestBody.setContent( - new Content() - .addMediaType("application/json", schema) - .addMediaType("application/xml", schema)); + Content content = new Content(); + for (String contentType : subroute.getRequestContentTypes()) { + content.addMediaType(contentType, schema); + } + requestBody.setContent(content); operation.setRequestBody(requestBody); } @@ -454,6 +458,20 @@ private void addRouteCustomHeaders( } } + private void addRouteResponseHeaders( + final RoutingDefinition subroute, final ApiResponse response) { + if (!subroute.hasResponseHeaders()) { + return; + } + + for (String headerName : subroute.getResponseHeaderNames()) { + Header header = new Header(); + header.setDescription(subroute.getResponseHeaderValue(headerName)); + header.setSchema(new StringSchema()); + response.addHeaderObject(headerName, header); + } + } + private void addHttpSecurityScheme( final Components components, final String name, 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 39f86efe..dd64e02c 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 @@ -28,464 +28,568 @@ public class RelationshipApiSqliteRepositoryTest { @Test public void getRelationshipPathsUseRepositoryWithoutLoadingCompatibilitySnapshot() { - try (Thingifier todoManager = - new Thingifier(new EntityRelModel(SqliteThingStoreProvider.inMemory()))) { - EntityDefinition todo = todoManager.defineThing("todo", "todos"); - todo.addAsPrimaryKeyField(Field.is("guid", FieldType.AUTO_GUID)); - todo.addField(Field.is("title", FieldType.STRING)); - - EntityDefinition project = todoManager.defineThing("project", "projects"); - project.addAsPrimaryKeyField(Field.is("guid", FieldType.AUTO_GUID)); - project.addField(Field.is("title", FieldType.STRING)); - - todoManager - .defineRelationship(project, todo, "tasks", Cardinality.ONE_TO_MANY()) - .whenReversed(Cardinality.ONE_TO_MANY(), "task-of"); - - ThingStore repository = todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); - - EntityInstance task = - repository - .entities() - .create( - EntityInstanceDraft.forEntity(todo) - .withField("title", "SQLite relationship task")); - EntityInstance projectInstance = - repository - .entities() - .create( - EntityInstanceDraft.forEntity(project) - .withField("title", "SQLite relationship project")); - - repository.relationships().connect(projectInstance, "tasks", task); - - ApiResponse tasksResponse = - todoManager - .api() - .get( - String.format( - "project/%s/tasks", - projectInstance.getPrimaryKeyValue()), - new QueryFilterParams(), - new HttpHeadersBlock()); - - Assertions.assertEquals(200, tasksResponse.getStatusCode()); - Assertions.assertTrue(tasksResponse.isCollection()); - Assertions.assertEquals(1, tasksResponse.getReturnedInstanceCollection().size()); - Assertions.assertEquals( - task.getPrimaryKeyValue(), - tasksResponse.getReturnedInstanceCollection().get(0).getPrimaryKeyValue()); - - ApiResponse projectResponse = + withFreshSqliteThingifier( + todoManager -> { + EntityDefinition todo = todoManager.defineThing("todo", "todos"); + todo.addAsPrimaryKeyField(Field.is("guid", FieldType.AUTO_GUID)); + todo.addField(Field.is("title", FieldType.STRING)); + + EntityDefinition project = todoManager.defineThing("project", "projects"); + project.addAsPrimaryKeyField(Field.is("guid", FieldType.AUTO_GUID)); + project.addField(Field.is("title", FieldType.STRING)); + todoManager - .api() - .get( - String.format("todo/%s/task-of", task.getPrimaryKeyValue()), - new QueryFilterParams(), - new HttpHeadersBlock()); - - Assertions.assertEquals(200, projectResponse.getStatusCode()); - Assertions.assertTrue(projectResponse.isCollection()); - Assertions.assertEquals(1, projectResponse.getReturnedInstanceCollection().size()); - Assertions.assertEquals( - projectInstance.getPrimaryKeyValue(), - projectResponse.getReturnedInstanceCollection().get(0).getPrimaryKeyValue()); - } + .defineRelationship(project, todo, "tasks", Cardinality.ONE_TO_MANY()) + .whenReversed(Cardinality.ONE_TO_MANY(), "task-of"); + + ThingStore repository = + todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); + + EntityInstance task = + repository + .entities() + .create( + EntityInstanceDraft.forEntity(todo) + .withField( + "title", "SQLite relationship task")); + EntityInstance projectInstance = + repository + .entities() + .create( + EntityInstanceDraft.forEntity(project) + .withField( + "title", + "SQLite relationship project")); + + repository.relationships().connect(projectInstance, "tasks", task); + + ApiResponse tasksResponse = + todoManager + .api() + .get( + String.format( + "project/%s/tasks", + projectInstance.getPrimaryKeyValue()), + new QueryFilterParams(), + new HttpHeadersBlock()); + + Assertions.assertEquals(200, tasksResponse.getStatusCode()); + Assertions.assertTrue(tasksResponse.isCollection()); + Assertions.assertEquals( + 1, tasksResponse.getReturnedInstanceCollection().size()); + Assertions.assertEquals( + task.getPrimaryKeyValue(), + tasksResponse + .getReturnedInstanceCollection() + .get(0) + .getPrimaryKeyValue()); + + ApiResponse projectResponse = + todoManager + .api() + .get( + String.format( + "todo/%s/task-of", task.getPrimaryKeyValue()), + new QueryFilterParams(), + new HttpHeadersBlock()); + + Assertions.assertEquals(200, projectResponse.getStatusCode()); + Assertions.assertTrue(projectResponse.isCollection()); + Assertions.assertEquals( + 1, projectResponse.getReturnedInstanceCollection().size()); + Assertions.assertEquals( + projectInstance.getPrimaryKeyValue(), + projectResponse + .getReturnedInstanceCollection() + .get(0) + .getPrimaryKeyValue()); + }); } @Test public void getUnsupportedRelationshipTraversalDoesNotUseCompatibilityFallback() { - try (Thingifier todoManager = sqliteTodoManager()) { - ThingStore repository = todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); - EntityDefinition todo = todoManager.getDefinitionNamed("todo"); - EntityDefinition project = todoManager.getDefinitionNamed("project"); - - EntityInstance task = - repository - .entities() - .create( - EntityInstanceDraft.forEntity(todo) - .withField("title", "SQLite relationship task")); - EntityInstance projectInstance = - repository - .entities() - .create( - EntityInstanceDraft.forEntity(project) - .withField("title", "SQLite relationship project")); - repository.relationships().connect(projectInstance, "tasks", task); - - ApiResponse response = - todoManager - .api() - .get( - String.format( - "project/%s/todo", - projectInstance.getPrimaryKeyValue()), - new QueryFilterParams(), - new HttpHeadersBlock()); - - Assertions.assertEquals(404, response.getStatusCode()); - } + withSqliteTodoManager( + todoManager -> { + ThingStore repository = + todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); + EntityDefinition todo = todoManager.getDefinitionNamed("todo"); + EntityDefinition project = todoManager.getDefinitionNamed("project"); + + EntityInstance task = + repository + .entities() + .create( + EntityInstanceDraft.forEntity(todo) + .withField( + "title", "SQLite relationship task")); + EntityInstance projectInstance = + repository + .entities() + .create( + EntityInstanceDraft.forEntity(project) + .withField( + "title", + "SQLite relationship project")); + repository.relationships().connect(projectInstance, "tasks", task); + + ApiResponse response = + todoManager + .api() + .get( + String.format( + "project/%s/todo", + projectInstance.getPrimaryKeyValue()), + new QueryFilterParams(), + new HttpHeadersBlock()); + + Assertions.assertEquals(404, response.getStatusCode()); + }); } @Test public void postRelationshipPathConnectsExistingItemWithoutLoadingCompatibilitySnapshot() { - try (Thingifier todoManager = sqliteTodoManager()) { - ThingStore repository = todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); - EntityDefinition todo = todoManager.getDefinitionNamed("todo"); - EntityDefinition project = todoManager.getDefinitionNamed("project"); - - EntityInstance task = - repository - .entities() - .create( - EntityInstanceDraft.forEntity(todo) - .withField("title", "SQLite relationship task")); - EntityInstance projectInstance = - repository - .entities() - .create( - EntityInstanceDraft.forEntity(project) - .withField("title", "SQLite relationship project")); - - Map body = new HashMap<>(); - body.put("guid", task.getPrimaryKeyValue()); - - ApiResponse response = - todoManager - .api() - .post( - String.format( - "project/%s/tasks", - projectInstance.getPrimaryKeyValue()), - parserFor(todoManager, body), - new HttpHeadersBlock()); - - Assertions.assertEquals(201, response.getStatusCode()); - Assertions.assertEquals( - 1, repository.relationships().listRelated(projectInstance, "tasks").size()); - Assertions.assertEquals( - task.getPrimaryKeyValue(), - repository - .relationships() - .listRelated(projectInstance, "tasks") - .get(0) - .getPrimaryKeyValue()); - } + withSqliteTodoManager( + todoManager -> { + ThingStore repository = + todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); + EntityDefinition todo = todoManager.getDefinitionNamed("todo"); + EntityDefinition project = todoManager.getDefinitionNamed("project"); + + EntityInstance task = + repository + .entities() + .create( + EntityInstanceDraft.forEntity(todo) + .withField( + "title", "SQLite relationship task")); + EntityInstance projectInstance = + repository + .entities() + .create( + EntityInstanceDraft.forEntity(project) + .withField( + "title", + "SQLite relationship project")); + + Map body = new HashMap<>(); + body.put("guid", task.getPrimaryKeyValue()); + + ApiResponse response = + todoManager + .api() + .post( + String.format( + "project/%s/tasks", + projectInstance.getPrimaryKeyValue()), + parserFor(todoManager, body), + new HttpHeadersBlock()); + + Assertions.assertEquals(201, response.getStatusCode()); + Assertions.assertEquals( + 1, + repository + .relationships() + .listRelated(projectInstance, "tasks") + .size()); + Assertions.assertEquals( + task.getPrimaryKeyValue(), + repository + .relationships() + .listRelated(projectInstance, "tasks") + .get(0) + .getPrimaryKeyValue()); + }); } @Test public void postReverseRelationshipPathConnectsExistingItemWithoutLoadingCompatibilitySnapshot() { - try (Thingifier todoManager = sqliteTodoManager()) { - ThingStore repository = todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); - EntityDefinition todo = todoManager.getDefinitionNamed("todo"); - EntityDefinition project = todoManager.getDefinitionNamed("project"); - - EntityInstance task = - repository - .entities() - .create( - EntityInstanceDraft.forEntity(todo) - .withField("title", "SQLite relationship task")); - EntityInstance projectInstance = - repository - .entities() - .create( - EntityInstanceDraft.forEntity(project) - .withField("title", "SQLite relationship project")); - - Map body = new HashMap<>(); - body.put("guid", projectInstance.getPrimaryKeyValue()); - - ApiResponse response = - todoManager - .api() - .post( - String.format("todo/%s/task-of", task.getPrimaryKeyValue()), - parserFor(todoManager, body), - new HttpHeadersBlock()); - - Assertions.assertEquals(201, response.getStatusCode()); - Assertions.assertEquals( - 1, repository.relationships().listRelated(task, "task-of").size()); - Assertions.assertEquals( - projectInstance.getPrimaryKeyValue(), - repository - .relationships() - .listRelated(task, "task-of") - .get(0) - .getPrimaryKeyValue()); - } + withSqliteTodoManager( + todoManager -> { + ThingStore repository = + todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); + EntityDefinition todo = todoManager.getDefinitionNamed("todo"); + EntityDefinition project = todoManager.getDefinitionNamed("project"); + + EntityInstance task = + repository + .entities() + .create( + EntityInstanceDraft.forEntity(todo) + .withField( + "title", "SQLite relationship task")); + EntityInstance projectInstance = + repository + .entities() + .create( + EntityInstanceDraft.forEntity(project) + .withField( + "title", + "SQLite relationship project")); + + Map body = new HashMap<>(); + body.put("guid", projectInstance.getPrimaryKeyValue()); + + ApiResponse response = + todoManager + .api() + .post( + String.format( + "todo/%s/task-of", task.getPrimaryKeyValue()), + parserFor(todoManager, body), + new HttpHeadersBlock()); + + Assertions.assertEquals(201, response.getStatusCode()); + Assertions.assertEquals( + 1, repository.relationships().listRelated(task, "task-of").size()); + Assertions.assertEquals( + projectInstance.getPrimaryKeyValue(), + repository + .relationships() + .listRelated(task, "task-of") + .get(0) + .getPrimaryKeyValue()); + }); } @Test public void postRelationshipPathCreatesAndConnectsNewItemWithoutLoadingCompatibilitySnapshot() { - try (Thingifier todoManager = sqliteTodoManager()) { - ThingStore repository = todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); - EntityDefinition todo = todoManager.getDefinitionNamed("todo"); - EntityDefinition project = todoManager.getDefinitionNamed("project"); - - EntityInstance projectInstance = - repository - .entities() - .create( - EntityInstanceDraft.forEntity(project) - .withField("title", "SQLite relationship project")); - - Map body = new HashMap<>(); - body.put("title", "created through relationship path"); - - ApiResponse response = - todoManager - .api() - .post( - String.format( - "project/%s/tasks", - projectInstance.getPrimaryKeyValue()), - parserFor(todoManager, body), - new HttpHeadersBlock()); - - Assertions.assertEquals(201, response.getStatusCode()); - List relatedTasks = - repository.relationships().listRelated(projectInstance, "tasks"); - Assertions.assertEquals(1, relatedTasks.size()); - Assertions.assertEquals( - "created through relationship path", - relatedTasks.get(0).getFieldValue("title").asString()); - Assertions.assertEquals(1, repository.entityQueries().list(todo).size()); - } + withSqliteTodoManager( + todoManager -> { + ThingStore repository = + todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); + EntityDefinition todo = todoManager.getDefinitionNamed("todo"); + EntityDefinition project = todoManager.getDefinitionNamed("project"); + + EntityInstance projectInstance = + repository + .entities() + .create( + EntityInstanceDraft.forEntity(project) + .withField( + "title", + "SQLite relationship project")); + + Map body = new HashMap<>(); + body.put("title", "created through relationship path"); + + ApiResponse response = + todoManager + .api() + .post( + String.format( + "project/%s/tasks", + projectInstance.getPrimaryKeyValue()), + parserFor(todoManager, body), + new HttpHeadersBlock()); + + Assertions.assertEquals(201, response.getStatusCode()); + List relatedTasks = + repository.relationships().listRelated(projectInstance, "tasks"); + Assertions.assertEquals(1, relatedTasks.size()); + Assertions.assertEquals( + "created through relationship path", + relatedTasks.get(0).getFieldValue("title").asString()); + Assertions.assertEquals(1, repository.entityQueries().list(todo).size()); + }); } @Test public void failedRelationshipPathDoesNotDeleteExistingRelatedItem() { - try (Thingifier todoManager = sqliteTodoManagerWithMandatoryCategory()) { - ThingStore repository = todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); - EntityDefinition todo = todoManager.getDefinitionNamed("todo"); - EntityDefinition project = todoManager.getDefinitionNamed("project"); - - EntityInstance task = - repository - .entities() - .create( - EntityInstanceDraft.forEntity(todo) - .withField("title", "Existing invalid task")); - EntityInstance projectInstance = - repository - .entities() - .create( - EntityInstanceDraft.forEntity(project) - .withField("title", "SQLite relationship project")); - - Map body = new HashMap<>(); - body.put("guid", task.getPrimaryKeyValue()); - - ApiResponse response = - todoManager - .api() - .post( - String.format( - "project/%s/tasks", - projectInstance.getPrimaryKeyValue()), - parserFor(todoManager, body), - new HttpHeadersBlock()); - - Assertions.assertEquals(422, response.getStatusCode()); - Assertions.assertNotNull( - repository - .entityQueries() - .findByQueryIdentifier(todo, task.getPrimaryKeyValue())); - Assertions.assertTrue( - repository.relationships().listRelated(projectInstance, "tasks").isEmpty()); - } + withMandatoryCategoryTodoManager( + todoManager -> { + ThingStore repository = + todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); + EntityDefinition todo = todoManager.getDefinitionNamed("todo"); + EntityDefinition project = todoManager.getDefinitionNamed("project"); + + EntityInstance task = + repository + .entities() + .create( + EntityInstanceDraft.forEntity(todo) + .withField("title", "Existing invalid task")); + EntityInstance projectInstance = + repository + .entities() + .create( + EntityInstanceDraft.forEntity(project) + .withField( + "title", + "SQLite relationship project")); + + Map body = new HashMap<>(); + body.put("guid", task.getPrimaryKeyValue()); + + ApiResponse response = + todoManager + .api() + .post( + String.format( + "project/%s/tasks", + projectInstance.getPrimaryKeyValue()), + parserFor(todoManager, body), + new HttpHeadersBlock()); + + Assertions.assertEquals(422, response.getStatusCode()); + Assertions.assertNotNull( + repository + .entityQueries() + .findByQueryIdentifier(todo, task.getPrimaryKeyValue())); + Assertions.assertTrue( + repository + .relationships() + .listRelated(projectInstance, "tasks") + .isEmpty()); + }); } @Test public void failedRootCreateDoesNotPersistEntityWhenRelationshipInvariantFails() { - try (Thingifier todoManager = sqliteTodoManagerWithMandatoryCategory()) { - ThingStore repository = todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); - EntityDefinition todo = todoManager.getDefinitionNamed("todo"); - int todoCount = repository.entityQueries().count(todo); - - Map body = new HashMap<>(); - body.put("title", "No category means rollback"); - - ApiResponse response = - todoManager - .api() - .post("todo", parserFor(todoManager, body), new HttpHeadersBlock()); - - Assertions.assertEquals(422, response.getStatusCode()); - Assertions.assertEquals(todoCount, repository.entityQueries().count(todo)); - } + withMandatoryCategoryTodoManager( + todoManager -> { + ThingStore repository = + todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); + EntityDefinition todo = todoManager.getDefinitionNamed("todo"); + int todoCount = repository.entityQueries().count(todo); + + Map body = new HashMap<>(); + body.put("title", "No category means rollback"); + + ApiResponse response = + todoManager + .api() + .post( + "todo", + parserFor(todoManager, body), + new HttpHeadersBlock()); + + Assertions.assertEquals(422, response.getStatusCode()); + Assertions.assertEquals(todoCount, repository.entityQueries().count(todo)); + }); } @Test public void relationshipPathCreateCanSatisfyMandatoryRelationshipInSameCommand() { - try (Thingifier todoManager = sqliteTaskProjectModelWithMandatoryTaskProject()) { - ThingStore repository = todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); - EntityDefinition task = todoManager.getDefinitionNamed("task"); - EntityDefinition project = todoManager.getDefinitionNamed("project"); - int taskCount = repository.entityQueries().count(task); - EntityInstance projectInstance = - repository - .entities() - .create( - EntityInstanceDraft.forEntity(project) - .withField("title", "Project for new task")); - - Map body = new HashMap<>(); - body.put("title", "Task created through relationship path"); - - ApiResponse response = - todoManager - .api() - .post( - String.format( - "project/%s/tasks", - projectInstance.getPrimaryKeyValue()), - parserFor(todoManager, body), - new HttpHeadersBlock()); - - Assertions.assertEquals(201, response.getStatusCode()); - Assertions.assertEquals(taskCount + 1, repository.entityQueries().count(task)); - Assertions.assertEquals( - 1, repository.relationships().listRelated(projectInstance, "tasks").size()); - Assertions.assertTrue( - repository.relationships().validate(response.getReturnedInstance()).isValid()); - } + withMandatoryTaskProjectModel( + todoManager -> { + ThingStore repository = + todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); + EntityDefinition task = todoManager.getDefinitionNamed("task"); + EntityDefinition project = todoManager.getDefinitionNamed("project"); + int taskCount = repository.entityQueries().count(task); + EntityInstance projectInstance = + repository + .entities() + .create( + EntityInstanceDraft.forEntity(project) + .withField("title", "Project for new task")); + + Map body = new HashMap<>(); + body.put("title", "Task created through relationship path"); + + ApiResponse response = + todoManager + .api() + .post( + String.format( + "project/%s/tasks", + projectInstance.getPrimaryKeyValue()), + parserFor(todoManager, body), + new HttpHeadersBlock()); + + Assertions.assertEquals(201, response.getStatusCode()); + Assertions.assertEquals(taskCount + 1, repository.entityQueries().count(task)); + Assertions.assertEquals( + 1, + repository + .relationships() + .listRelated(projectInstance, "tasks") + .size()); + Assertions.assertTrue( + repository + .relationships() + .validate(response.getReturnedInstance()) + .isValid()); + }); } @Test public void failedRelationshipAmendRestoresFieldsAndRelationships() { - try (Thingifier todoManager = sqliteTaskProjectModel()) { - ThingStore repository = todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); - EntityDefinition taskDefinition = todoManager.getDefinitionNamed("task"); - EntityDefinition projectDefinition = todoManager.getDefinitionNamed("project"); - - EntityInstance task = - repository - .entities() - .create( - EntityInstanceDraft.forEntity(taskDefinition) - .withField("title", "Original title")); - EntityInstance originalProject = - repository - .entities() - .create( - EntityInstanceDraft.forEntity(projectDefinition) - .withField("title", "Original project")); - EntityInstance rejectedProject = - repository - .entities() - .create( - EntityInstanceDraft.forEntity(projectDefinition) - .withField("title", "Rejected project")); - repository.relationships().connect(task, "task-of", originalProject); - - Map body = new HashMap<>(); - body.put("title", "Should not persist"); - body.put("task-of.guid", rejectedProject.getPrimaryKeyValue()); - - ApiResponse response = - todoManager - .api() - .post( - String.format("task/%s", task.getPrimaryKeyValue()), - parserFor(todoManager, body), - new HttpHeadersBlock()); - - EntityInstance restoredTask = - repository - .entityQueries() - .findByQueryIdentifier(taskDefinition, task.getPrimaryKeyValue()); - List relatedProjects = - repository.relationships().listRelated(restoredTask, "task-of"); - - Assertions.assertEquals(422, response.getStatusCode()); - Assertions.assertEquals( - "Original title", restoredTask.getFieldValue("title").asString()); - Assertions.assertEquals(1, relatedProjects.size()); - Assertions.assertEquals( - originalProject.getPrimaryKeyValue(), - relatedProjects.get(0).getPrimaryKeyValue()); - } + withTaskProjectModel( + todoManager -> { + ThingStore repository = + todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); + EntityDefinition taskDefinition = todoManager.getDefinitionNamed("task"); + EntityDefinition projectDefinition = todoManager.getDefinitionNamed("project"); + + EntityInstance task = + repository + .entities() + .create( + EntityInstanceDraft.forEntity(taskDefinition) + .withField("title", "Original title")); + EntityInstance originalProject = + repository + .entities() + .create( + EntityInstanceDraft.forEntity(projectDefinition) + .withField("title", "Original project")); + EntityInstance rejectedProject = + repository + .entities() + .create( + EntityInstanceDraft.forEntity(projectDefinition) + .withField("title", "Rejected project")); + repository.relationships().connect(task, "task-of", originalProject); + + Map body = new HashMap<>(); + body.put("title", "Should not persist"); + body.put("task-of.guid", rejectedProject.getPrimaryKeyValue()); + + ApiResponse response = + todoManager + .api() + .post( + String.format("task/%s", task.getPrimaryKeyValue()), + parserFor(todoManager, body), + new HttpHeadersBlock()); + + EntityInstance restoredTask = + repository + .entityQueries() + .findByQueryIdentifier( + taskDefinition, task.getPrimaryKeyValue()); + List relatedProjects = + repository.relationships().listRelated(restoredTask, "task-of"); + + Assertions.assertEquals(422, response.getStatusCode()); + Assertions.assertEquals( + "Original title", restoredTask.getFieldValue("title").asString()); + Assertions.assertEquals(1, relatedProjects.size()); + Assertions.assertEquals( + originalProject.getPrimaryKeyValue(), + relatedProjects.get(0).getPrimaryKeyValue()); + }); } @Test public void deleteRelationshipPathRemovesOnlyRelationshipWithoutLoadingCompatibilitySnapshot() { - try (Thingifier todoManager = sqliteTodoManager()) { - ThingStore repository = todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); - EntityDefinition todo = todoManager.getDefinitionNamed("todo"); - EntityDefinition project = todoManager.getDefinitionNamed("project"); - - EntityInstance task = - repository - .entities() - .create( - EntityInstanceDraft.forEntity(todo) - .withField("title", "SQLite relationship task")); - EntityInstance projectInstance = - repository - .entities() - .create( - EntityInstanceDraft.forEntity(project) - .withField("title", "SQLite relationship project")); - repository.relationships().connect(projectInstance, "tasks", task); - - ApiResponse response = - todoManager - .api() - .delete( - String.format( - "project/%s/tasks/%s", - projectInstance.getPrimaryKeyValue(), - task.getPrimaryKeyValue()), - new HttpHeadersBlock()); - - Assertions.assertEquals(204, response.getStatusCode()); - Assertions.assertTrue( - repository.relationships().listRelated(projectInstance, "tasks").isEmpty()); - Assertions.assertNotNull( - repository - .entityQueries() - .findByQueryIdentifier(todo, task.getPrimaryKeyValue())); - } + withSqliteTodoManager( + todoManager -> { + ThingStore repository = + todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); + EntityDefinition todo = todoManager.getDefinitionNamed("todo"); + EntityDefinition project = todoManager.getDefinitionNamed("project"); + + EntityInstance task = + repository + .entities() + .create( + EntityInstanceDraft.forEntity(todo) + .withField( + "title", "SQLite relationship task")); + EntityInstance projectInstance = + repository + .entities() + .create( + EntityInstanceDraft.forEntity(project) + .withField( + "title", + "SQLite relationship project")); + repository.relationships().connect(projectInstance, "tasks", task); + + ApiResponse response = + todoManager + .api() + .delete( + String.format( + "project/%s/tasks/%s", + projectInstance.getPrimaryKeyValue(), + task.getPrimaryKeyValue()), + new HttpHeadersBlock()); + + Assertions.assertEquals(204, response.getStatusCode()); + Assertions.assertTrue( + repository + .relationships() + .listRelated(projectInstance, "tasks") + .isEmpty()); + Assertions.assertNotNull( + repository + .entityQueries() + .findByQueryIdentifier(todo, task.getPrimaryKeyValue())); + }); } @Test public void deleteMissingRelationshipPathReturns404WithoutLoadingCompatibilitySnapshot() { - try (Thingifier todoManager = sqliteTodoManager()) { - ThingStore repository = todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); - EntityDefinition todo = todoManager.getDefinitionNamed("todo"); - EntityDefinition project = todoManager.getDefinitionNamed("project"); - - EntityInstance task = - repository - .entities() - .create( - EntityInstanceDraft.forEntity(todo) - .withField("title", "SQLite relationship task")); - EntityInstance projectInstance = - repository - .entities() - .create( - EntityInstanceDraft.forEntity(project) - .withField("title", "SQLite relationship project")); - - ApiResponse response = - todoManager - .api() - .delete( - String.format( - "project/%s/tasks/%s", - projectInstance.getPrimaryKeyValue(), - task.getPrimaryKeyValue()), - new HttpHeadersBlock()); - - Assertions.assertEquals(404, response.getStatusCode()); - Assertions.assertTrue( - repository.relationships().listRelated(projectInstance, "tasks").isEmpty()); + withSqliteTodoManager( + todoManager -> { + ThingStore repository = + todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); + EntityDefinition todo = todoManager.getDefinitionNamed("todo"); + EntityDefinition project = todoManager.getDefinitionNamed("project"); + + EntityInstance task = + repository + .entities() + .create( + EntityInstanceDraft.forEntity(todo) + .withField( + "title", "SQLite relationship task")); + EntityInstance projectInstance = + repository + .entities() + .create( + EntityInstanceDraft.forEntity(project) + .withField( + "title", + "SQLite relationship project")); + + ApiResponse response = + todoManager + .api() + .delete( + String.format( + "project/%s/tasks/%s", + projectInstance.getPrimaryKeyValue(), + task.getPrimaryKeyValue()), + new HttpHeadersBlock()); + + Assertions.assertEquals(404, response.getStatusCode()); + Assertions.assertTrue( + repository + .relationships() + .listRelated(projectInstance, "tasks") + .isEmpty()); + }); + } + + private void withFreshSqliteThingifier(final ThingifierScenario scenario) { + withThingifier( + new Thingifier(new EntityRelModel(SqliteThingStoreProvider.inMemory())), scenario); + } + + private void withSqliteTodoManager(final ThingifierScenario scenario) { + withThingifier(sqliteTodoManager(), scenario); + } + + private void withMandatoryCategoryTodoManager(final ThingifierScenario scenario) { + withThingifier(sqliteTodoManagerWithMandatoryCategory(), scenario); + } + + private void withMandatoryTaskProjectModel(final ThingifierScenario scenario) { + withThingifier(sqliteTaskProjectModelWithMandatoryTaskProject(), scenario); + } + + private void withTaskProjectModel(final ThingifierScenario scenario) { + withThingifier(sqliteTaskProjectModel(), scenario); + } + + private void withThingifier(final Thingifier todoManager, final ThingifierScenario scenario) { + try (Thingifier openThingifier = todoManager) { + scenario.run(openThingifier); } } @@ -558,4 +662,9 @@ private BodyParser parserFor(final Thingifier todoManager, final Map response = - get("http://localhost:" + port + "/css/default.css"); - - Assertions.assertEquals(200, response.statusCode()); - Assertions.assertEquals( - "max-age=0", response.headers().firstValue("Cache-Control").orElse("")); - } - } finally { - restoreStaticCacheControlProperty(originalValue); - } + withStaticCacheControlProperty( + null, + () -> + withStartedServer( + port -> { + HttpResponse response = + get("http://localhost:" + port + "/css/default.css"); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertEquals( + "max-age=0", + response.headers() + .firstValue("Cache-Control") + .orElse("")); + })); } @Test void classpathStaticAssetsCanUseConfiguredCacheHeader() throws Exception { - final String originalValue = - System.getProperty(JavalinHttpServer.STATIC_CACHE_CONTROL_PROPERTY); final String cacheControl = "public, max-age=31536000, immutable"; - System.setProperty(JavalinHttpServer.STATIC_CACHE_CONTROL_PROPERTY, cacheControl); - try { - int port = availablePort(); - HttpRouteRegistry registry = new HttpRouteRegistry(); - - try (JavalinHttpServer server = new JavalinHttpServer(port, "/public", registry)) { - server.start(); - - HttpResponse response = - get("http://localhost:" + port + "/css/default.css"); - - Assertions.assertEquals(200, response.statusCode()); - Assertions.assertEquals( - cacheControl, response.headers().firstValue("Cache-Control").orElse("")); - } - } finally { - restoreStaticCacheControlProperty(originalValue); - } + withStaticCacheControlProperty( + cacheControl, + () -> + withStartedServer( + port -> { + HttpResponse response = + get("http://localhost:" + port + "/css/default.css"); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertEquals( + cacheControl, + response.headers() + .firstValue("Cache-Control") + .orElse("")); + })); } @Test void emptyNoContentDoesNotReturnContentTypeHeader() throws Exception { - int port = availablePort(); - HttpRouteRegistry registry = new HttpRouteRegistry(); - registry.add( - HttpRouteVerb.GET, - "/empty-no-content", - (request, response) -> { - response.type("application/json"); - response.status(204); - return ""; + withStartedServer( + registry -> + registry.add( + HttpRouteVerb.GET, + "/empty-no-content", + (request, response) -> { + response.type("application/json"); + response.status(204); + return ""; + }), + port -> { + HttpResponse response = + get("http://localhost:" + port + "/empty-no-content"); + + Assertions.assertEquals(204, response.statusCode()); + Assertions.assertEquals("", response.body()); + Assertions.assertTrue(response.headers().firstValue("Content-Type").isEmpty()); }); - - try (JavalinHttpServer server = new JavalinHttpServer(port, "/public", registry)) { - server.start(); - - HttpResponse response = get("http://localhost:" + port + "/empty-no-content"); - - Assertions.assertEquals(204, response.statusCode()); - Assertions.assertEquals("", response.body()); - Assertions.assertTrue(response.headers().firstValue("Content-Type").isEmpty()); - } } @Test @@ -104,131 +91,153 @@ void keepsSingleLegacyPathParamNamesReadable() { @Test void contentTypeCanBeSuppressedForResponseWithBody() throws Exception { - int port = availablePort(); - HttpRouteRegistry registry = new HttpRouteRegistry(); - registry.add( - HttpRouteVerb.GET, - "/no-content-type", - (request, response) -> { - response.suppressContentType(); - response.status(200); - return "{\"version\":\"6\"}"; + withStartedServer( + registry -> + registry.add( + HttpRouteVerb.GET, + "/no-content-type", + (request, response) -> { + response.suppressContentType(); + response.status(200); + return "{\"version\":\"6\"}"; + }), + port -> { + HttpResponse response = + get("http://localhost:" + port + "/no-content-type"); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertEquals("{\"version\":\"6\"}", response.body()); + Assertions.assertTrue(response.headers().firstValue("Content-Type").isEmpty()); }); - - try (JavalinHttpServer server = new JavalinHttpServer(port, "/public", registry)) { - server.start(); - - HttpResponse response = get("http://localhost:" + port + "/no-content-type"); - - Assertions.assertEquals(200, response.statusCode()); - Assertions.assertEquals("{\"version\":\"6\"}", response.body()); - Assertions.assertTrue(response.headers().firstValue("Content-Type").isEmpty()); - } } @Test void queryMethodRoutesThroughJavalinAdapter() throws Exception { - int port = availablePort(); - HttpRouteRegistry registry = new HttpRouteRegistry(); - registry.add( - HttpRouteVerb.QUERY, - "/search", - (request, response) -> { - response.type("text/plain"); - response.status(200); - return request.method() + ":" + request.body(); + withStartedServer( + registry -> + registry.add( + HttpRouteVerb.QUERY, + "/search", + (request, response) -> { + response.type("text/plain"); + response.status(200); + return request.method() + ":" + request.body(); + }), + port -> { + String response = rawHttp("QUERY", "/search", port, "title=Task"); + + Assertions.assertTrue(response.startsWith("HTTP/1.1 200 OK")); + Assertions.assertTrue(response.endsWith("QUERY:title=Task")); }); - - try (JavalinHttpServer server = new JavalinHttpServer(port, "/public", registry)) { - server.start(); - - String response = rawHttp("QUERY", "/search", port, "title=Task"); - - Assertions.assertTrue(response.startsWith("HTTP/1.1 200 OK")); - Assertions.assertTrue(response.endsWith("QUERY:title=Task")); - } } @Test void forcedBodyCanBeSentWithNoContentStatus() throws Exception { - int port = availablePort(); - HttpRouteRegistry registry = new HttpRouteRegistry(); - registry.add( - HttpRouteVerb.DELETE, - "/forced-no-content", - (request, response) -> { - response.type("application/json"); - response.status(204); - response.forceBody("{\"message\":\"forced\"}"); - return ""; + withStartedServer( + registry -> + registry.add( + HttpRouteVerb.DELETE, + "/forced-no-content", + (request, response) -> { + response.type("application/json"); + response.status(204); + response.forceBody("{\"message\":\"forced\"}"); + return ""; + }), + port -> { + String response = rawHttp("DELETE", "/forced-no-content", port); + + Assertions.assertTrue(response.startsWith("HTTP/1.1 204 No Content")); + Assertions.assertTrue(response.contains("Content-Type: application/json")); + Assertions.assertTrue(response.contains("Content-Length: 20")); + Assertions.assertTrue(response.endsWith("{\"message\":\"forced\"}")); }); - - try (JavalinHttpServer server = new JavalinHttpServer(port, "/public", registry)) { - server.start(); - - String response = rawHttp("DELETE", "/forced-no-content", port); - - Assertions.assertTrue(response.startsWith("HTTP/1.1 204 No Content")); - Assertions.assertTrue(response.contains("Content-Type: application/json")); - Assertions.assertTrue(response.contains("Content-Length: 20")); - Assertions.assertTrue(response.endsWith("{\"message\":\"forced\"}")); - } } @Test void emptyNotFoundDoesNotReturnContentTypeHeader() throws Exception { - int port = availablePort(); - HttpRouteRegistry registry = new HttpRouteRegistry(); - registry.add( - HttpRouteVerb.GET, - "/empty-not-found", - (request, response) -> { - response.type("application/json"); - response.status(404); - return ""; + withStartedServer( + registry -> + registry.add( + HttpRouteVerb.GET, + "/empty-not-found", + (request, response) -> { + response.type("application/json"); + response.status(404); + return ""; + }), + port -> { + HttpResponse response = + get("http://localhost:" + port + "/empty-not-found"); + + Assertions.assertEquals(404, response.statusCode()); + Assertions.assertEquals("", response.body()); + Assertions.assertTrue(response.headers().firstValue("Content-Type").isEmpty()); }); + } - try (JavalinHttpServer server = new JavalinHttpServer(port, "/public", registry)) { - server.start(); + @Test + void notFoundWithBodyKeepsContentTypeHeader() throws Exception { + withStartedServer( + registry -> + registry.add( + HttpRouteVerb.GET, + "/body-not-found", + (request, response) -> { + response.type("application/json"); + response.status(404); + return "{\"error\":\"missing\"}"; + }), + port -> { + HttpResponse response = + get("http://localhost:" + port + "/body-not-found"); + + Assertions.assertEquals(404, response.statusCode()); + Assertions.assertEquals("{\"error\":\"missing\"}", response.body()); + Assertions.assertEquals( + "application/json", + response.headers().firstValue("Content-Type").orElse("")); + }); + } - HttpResponse response = get("http://localhost:" + port + "/empty-not-found"); + private HttpResponse get(final String url) throws Exception { + return HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder(new URI(url)).GET().build(), + HttpResponse.BodyHandlers.ofString()); + } - Assertions.assertEquals(404, response.statusCode()); - Assertions.assertEquals("", response.body()); - Assertions.assertTrue(response.headers().firstValue("Content-Type").isEmpty()); - } + private void withStartedServer(final ServerRequest request) throws Exception { + withStartedServer(registry -> {}, request); } - @Test - void notFoundWithBodyKeepsContentTypeHeader() throws Exception { + private void withStartedServer(final RouteConfigurer routes, final ServerRequest request) + throws Exception { int port = availablePort(); HttpRouteRegistry registry = new HttpRouteRegistry(); - registry.add( - HttpRouteVerb.GET, - "/body-not-found", - (request, response) -> { - response.type("application/json"); - response.status(404); - return "{\"error\":\"missing\"}"; - }); + routes.configure(registry); try (JavalinHttpServer server = new JavalinHttpServer(port, "/public", registry)) { server.start(); - - HttpResponse response = get("http://localhost:" + port + "/body-not-found"); - - Assertions.assertEquals(404, response.statusCode()); - Assertions.assertEquals("{\"error\":\"missing\"}", response.body()); - Assertions.assertEquals( - "application/json", response.headers().firstValue("Content-Type").orElse("")); + request.run(port); } } - private HttpResponse get(final String url) throws Exception { - return HttpClient.newHttpClient() - .send( - HttpRequest.newBuilder(new URI(url)).GET().build(), - HttpResponse.BodyHandlers.ofString()); + private void withStaticCacheControlProperty( + final String configuredValue, final CheckedRunnable request) throws Exception { + final String originalValue = + System.getProperty(JavalinHttpServer.STATIC_CACHE_CONTROL_PROPERTY); + if (configuredValue == null) { + System.clearProperty(JavalinHttpServer.STATIC_CACHE_CONTROL_PROPERTY); + } else { + System.setProperty(JavalinHttpServer.STATIC_CACHE_CONTROL_PROPERTY, configuredValue); + } + + try { + request.run(); + } finally { + restoreStaticCacheControlProperty(originalValue); + } } private void restoreStaticCacheControlProperty(final String originalValue) { @@ -271,4 +280,19 @@ private String rawHttp( return new String(socket.getInputStream().readAllBytes(), StandardCharsets.UTF_8); } } + + @FunctionalInterface + private interface RouteConfigurer { + void configure(HttpRouteRegistry registry); + } + + @FunctionalInterface + private interface ServerRequest { + void run(int port) throws Exception; + } + + @FunctionalInterface + private interface CheckedRunnable { + void run() throws Exception; + } } diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinThingifierPatchRoutesTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinThingifierPatchRoutesTest.java new file mode 100644 index 00000000..c4ec996e --- /dev/null +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/adapter/javalin/JavalinThingifierPatchRoutesTest.java @@ -0,0 +1,270 @@ +package uk.co.compendiumdev.thingifier.adapter.javalin; + +import static uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle.JSON_PATCH_RFC6902; +import static uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle.PARTIAL_JSON_UPDATE; + +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import org.json.JSONObject; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.adapter.httpserver.HttpRouteRegistry; +import uk.co.compendiumdev.thingifier.adapter.httpserver.ThingifierHttpApiRoutings; +import uk.co.compendiumdev.thingifier.api.docgen.ThingifierApiDocumentationDefn; +import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock; +import uk.co.compendiumdev.thingifier.apiconfig.ApiDocsConfig; +import uk.co.compendiumdev.thingifier.apiconfig.ThingifierApiConfig; +import uk.co.compendiumdev.thingifier.apiconfig.ThingifierApiConfigProfiles; +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.query.QueryFilterParams; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Execution(ExecutionMode.SAME_THREAD) +class JavalinThingifierPatchRoutesTest { + + private Thingifier thingifier; + private JavalinHttpServer server; + private String baseUrl; + + @BeforeAll + void startServer() throws Exception { + thingifier = patchableNotesThingifier(); + HttpRouteRegistry registry = registerThingifierApiRoutes(thingifier); + int port = availablePort(); + server = new JavalinHttpServer(port, "/public", registry); + server.start(); + baseUrl = "http://localhost:" + port + "/api"; + } + + @BeforeEach + void clearBusinessData() { + thingifier.clearAllData(); + } + + @AfterAll + void stopServer() { + try { + if (server != null) { + server.close(); + } + } finally { + HttpRouteRegistry.clearCurrent(); + } + } + + @Test + void postCreatesANoteThroughTheThingifierServerRoutes() throws Exception { + EntityInstance createdNote = createNoteThroughServer("Original", "Keep"); + + Assertions.assertEquals("Original", createdNote.getFieldValue("title").asString()); + Assertions.assertEquals("Keep", createdNote.getFieldValue("description").asString()); + } + + @Test + void optionsAdvertisesPatchFormatsForPatchableEntityInstances() throws Exception { + EntityInstance createdNote = createNoteThroughServer("Original", "Keep"); + + HttpResponse options = + request( + "OPTIONS", + baseUrl + "/notes/" + createdNote.getPrimaryKeyValue(), + "", + null); + + Assertions.assertEquals(204, options.statusCode()); + Assertions.assertEquals( + "OPTIONS, GET, HEAD, POST, PUT, PATCH, DELETE", + options.headers().firstValue("Allow").orElse("")); + Assertions.assertEquals( + patchFormatsHeader(), options.headers().firstValue("Accept-Patch").orElse("")); + } + + @Test + void unsupportedPatchContentTypeReturns415AndDoesNotChangeTheNote() throws Exception { + EntityInstance createdNote = createNoteThroughServer("Original", "Keep"); + + HttpResponse response = + request( + "PATCH", + baseUrl + "/notes/" + createdNote.getPrimaryKeyValue(), + "{\"title\":\"Should Not Change\"}", + "application/merge-patch+json"); + + Assertions.assertEquals(415, response.statusCode()); + Assertions.assertEquals( + patchFormatsHeader(), response.headers().firstValue("Accept-Patch").orElse("")); + Assertions.assertEquals( + "Unsupported PATCH Content Type", + json(response).getJSONArray("errorMessages").getString(0)); + Assertions.assertEquals( + "Original", + currentNote(createdNote.getPrimaryKeyValue()).getFieldValue("title").asString()); + Assertions.assertEquals( + "Keep", + currentNote(createdNote.getPrimaryKeyValue()) + .getFieldValue("description") + .asString()); + } + + @Test + void partialJsonPatchUpdatesOnlyTheProvidedFieldsThroughTheServer() throws Exception { + EntityInstance createdNote = createNoteThroughServer("Original", "Keep"); + + HttpResponse response = + request( + "PATCH", + baseUrl + "/notes/" + createdNote.getPrimaryKeyValue(), + "{\"title\":\"Partial Updated\"}", + PARTIAL_JSON_UPDATE.mediaType()); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertEquals( + "application/json", response.headers().firstValue("Content-Type").orElse("")); + Assertions.assertEquals("Partial Updated", json(response).getString("title")); + Assertions.assertEquals("Keep", json(response).getString("description")); + Assertions.assertEquals( + "Partial Updated", + currentNote(createdNote.getPrimaryKeyValue()).getFieldValue("title").asString()); + Assertions.assertEquals( + "Keep", + currentNote(createdNote.getPrimaryKeyValue()) + .getFieldValue("description") + .asString()); + } + + @Test + void jsonPatchUpdatesTheTargetThroughTheServer() throws Exception { + EntityInstance createdNote = createNoteThroughServer("Original", "Keep"); + + HttpResponse response = + request( + "PATCH", + baseUrl + "/notes/" + createdNote.getPrimaryKeyValue(), + "[{\"op\":\"replace\",\"path\":\"/description\",\"value\":\"JSON Patch Updated\"}]", + JSON_PATCH_RFC6902.mediaType()); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertEquals("Original", json(response).getString("title")); + Assertions.assertEquals("JSON Patch Updated", json(response).getString("description")); + Assertions.assertEquals( + "Original", + currentNote(createdNote.getPrimaryKeyValue()).getFieldValue("title").asString()); + Assertions.assertEquals( + "JSON Patch Updated", + currentNote(createdNote.getPrimaryKeyValue()) + .getFieldValue("description") + .asString()); + } + + private EntityInstance createNoteThroughServer(final String title, final String description) + throws Exception { + HttpResponse created = + request( + "POST", + baseUrl + "/notes", + "{\"title\":\"" + title + "\",\"description\":\"" + description + "\"}", + PARTIAL_JSON_UPDATE.mediaType()); + + Assertions.assertEquals(201, created.statusCode()); + Assertions.assertEquals(title, json(created).getString("title")); + Assertions.assertEquals(description, json(created).getString("description")); + + EntityInstance createdNote = onlyNote(); + Assertions.assertEquals(title, createdNote.getFieldValue("title").asString()); + Assertions.assertEquals(description, createdNote.getFieldValue("description").asString()); + return createdNote; + } + + private HttpResponse request( + final String method, final String url, final String body, final String contentType) + throws Exception { + HttpRequest.Builder builder = + HttpRequest.newBuilder(new URI(url)).header("Accept", "application/json"); + if (contentType != null) { + builder.header("Content-Type", contentType); + } + return HttpClient.newHttpClient() + .send( + builder.method( + method, + body == null || body.isEmpty() + ? HttpRequest.BodyPublishers.noBody() + : HttpRequest.BodyPublishers.ofString(body)) + .build(), + HttpResponse.BodyHandlers.ofString()); + } + + private JSONObject json(final HttpResponse response) { + return new JSONObject(response.body()); + } + + private String patchFormatsHeader() { + return String.join(", ", PARTIAL_JSON_UPDATE.mediaType(), JSON_PATCH_RFC6902.mediaType()); + } + + private Thingifier patchableNotesThingifier() { + Thingifier thingifier = + new Thingifier( + new EntityRelModel(), + new ThingifierApiConfig("api"), + new ThingifierApiConfigProfiles(), + "", + "", + new ApiDocsConfig()); + EntityDefinition note = thingifier.defineThing("note", "notes"); + note.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT)); + note.addField(Field.is("title", FieldType.STRING).makeMandatory()); + note.addField(Field.is("description", FieldType.STRING)); + thingifier + .apiConfig() + .writeMethods() + .entities() + .patchCan(PARTIAL_JSON_UPDATE, JSON_PATCH_RFC6902); + return thingifier; + } + + private HttpRouteRegistry registerThingifierApiRoutes(final Thingifier thingifier) { + HttpRouteRegistry registry = new HttpRouteRegistry(); + HttpRouteRegistry.use(registry); + ThingifierApiDocumentationDefn apiDefn = + new ThingifierApiDocumentationDefn().setThingifier(thingifier); + apiDefn.setPathPrefix("api"); + new ThingifierHttpApiRoutings(thingifier, apiDefn); + return registry; + } + + private EntityInstance currentNote(final String id) { + return thingifier + .api() + .get("notes/" + id, new QueryFilterParams(), new HttpHeadersBlock()) + .getReturnedInstance(); + } + + private EntityInstance onlyNote() { + return thingifier + .api() + .get("notes", new QueryFilterParams(), new HttpHeadersBlock()) + .getReturnedInstanceCollection() + .get(0); + } + + private int availablePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } +} 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 0f920b2c..54cf9360 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 @@ -1,5 +1,8 @@ package uk.co.compendiumdev.thingifier.api.restapihandlers; +import static uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle.JSON_MERGE_PATCH_RFC7396; +import static uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle.JSON_PATCH_RFC6902; +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.RelationshipWriteOperation.CONNECT_EXISTING; @@ -26,6 +29,7 @@ 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; +import uk.co.compendiumdev.thingifier.core.query.QueryFilterParams; public class WriteMethodPolicyTest { @@ -91,7 +95,7 @@ public void putUsesExistingTargetStateToResolveCreateOrUpdate() { @Test public void patchCanBeEnabledForEntityInstanceUpdates() { Thingifier thingifier = stringIdNotes(); - thingifier.apiConfig().writeMethods().entities().patchCan(UPDATE); + thingifier.apiConfig().writeMethods().entities().patchCan(PARTIAL_JSON_UPDATE); createNote(thingifier, "one", "One"); ApiResponse response = patch(thingifier, "notes/one", "{\"title\":\"Patched\"}"); @@ -101,6 +105,322 @@ public void patchCanBeEnabledForEntityInstanceUpdates() { "Patched", response.getReturnedInstance().getFieldValue("title").asString()); } + @Test + public void patchIsRejectedForEntityCollectionsEvenWhenInstancePatchIsAllowed() { + Thingifier thingifier = stringIdNotes(); + thingifier.apiConfig().writeMethods().entities().patchCan(PARTIAL_JSON_UPDATE); + + ApiResponse response = patch(thingifier, "notes", "{\"title\":\"No Collection Patch\"}"); + + Assertions.assertEquals(405, response.getStatusCode()); + Assertions.assertEquals( + "OPTIONS, GET, HEAD, POST, QUERY", response.getHeaderValue("Allow")); + Assertions.assertTrue(response.getErrorMessages().contains("Method Not Allowed")); + Assertions.assertEquals(0, noteCount(thingifier)); + } + + @Test + public void patchRequiresConfiguredContentTypeWhenMethodIsAllowed() { + Thingifier thingifier = stringIdNotes(); + thingifier.apiConfig().writeMethods().entities().patchCan(PARTIAL_JSON_UPDATE); + createNote(thingifier, "one", "One"); + + ApiResponse missingContentType = + thingifier + .api() + .patch("notes/one", "{\"title\":\"Blocked\"}", new HttpHeadersBlock()); + + HttpHeadersBlock mergePatchHeaders = new HttpHeadersBlock(); + mergePatchHeaders.put("Content-Type", JSON_MERGE_PATCH_RFC7396.mediaType()); + ApiResponse unsupportedContentType = + thingifier.api().patch("notes/one", "{\"title\":\"Blocked\"}", mergePatchHeaders); + + Assertions.assertEquals(415, missingContentType.getStatusCode()); + Assertions.assertEquals( + PARTIAL_JSON_UPDATE.mediaType(), missingContentType.getHeaderValue("Accept-Patch")); + Assertions.assertEquals(415, unsupportedContentType.getStatusCode()); + Assertions.assertEquals( + PARTIAL_JSON_UPDATE.mediaType(), + unsupportedContentType.getHeaderValue("Accept-Patch")); + Assertions.assertEquals( + "One", currentTitle(thingifier, "one"), "Unsupported PATCH must not amend data"); + Assertions.assertTrue( + unsupportedContentType + .getErrorMessages() + .contains("Unsupported PATCH Content Type")); + } + + @Test + public void patchContentTypeCanIncludeParameters() { + Thingifier thingifier = stringIdNotes(); + thingifier.apiConfig().writeMethods().entities().patchCan(PARTIAL_JSON_UPDATE); + createNote(thingifier, "one", "One"); + + HttpHeadersBlock headers = new HttpHeadersBlock(); + headers.put("Content-Type", "application/json; charset=utf-8"); + ApiResponse response = + thingifier.api().patch("notes/one", "{\"title\":\"Patched\"}", headers); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals( + "Patched", response.getReturnedInstance().getFieldValue("title").asString()); + } + + @Test + public void bodyParserPatchEntryPointPreservesJsonPatchArrayBodies() { + Thingifier thingifier = stringIdNotes(); + thingifier.apiConfig().writeMethods().entities().patchCan(JSON_PATCH_RFC6902); + createNote(thingifier, "one", "One", "Original"); + + HttpHeadersBlock headers = new HttpHeadersBlock(); + headers.put("Content-Type", JSON_PATCH_RFC6902.mediaType()); + ApiResponse response = + thingifier + .api() + .patch( + "notes/one", + parser( + thingifier, + "[{\"op\":\"replace\",\"path\":\"/description\",\"value\":\"From BodyParser\"}]"), + headers); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals( + "From BodyParser", + response.getReturnedInstance().getFieldValue("description").asString()); + Assertions.assertEquals("One", currentTitle(thingifier, "one")); + } + + @Test + public void jsonMergePatchCanUpdateAndRemoveFields() { + Thingifier thingifier = stringIdNotes(); + thingifier.apiConfig().writeMethods().entities().patchCan(JSON_MERGE_PATCH_RFC7396); + createNote(thingifier, "one", "One", "remove me"); + + ApiResponse response = + patch( + thingifier, + "notes/one", + "{\"title\":\"Merged\",\"description\":null}", + JSON_MERGE_PATCH_RFC7396.mediaType()); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals( + "Merged", response.getReturnedInstance().getFieldValue("title").asString()); + Assertions.assertEquals( + "", response.getReturnedInstance().getFieldValue("description").asString()); + } + + @Test + public void jsonMergePatchRejectsRootReplacementForEntityResources() { + Thingifier thingifier = stringIdNotes(); + thingifier.apiConfig().writeMethods().entities().patchCan(JSON_MERGE_PATCH_RFC7396); + createNote(thingifier, "one", "One"); + + ApiResponse response = + patch( + thingifier, + "notes/one", + "\"not an entity object\"", + JSON_MERGE_PATCH_RFC7396.mediaType()); + + Assertions.assertEquals(422, response.getStatusCode()); + Assertions.assertEquals( + "One", + thingifier + .api() + .get("notes/one", new QueryFilterParams(), new HttpHeadersBlock()) + .getReturnedInstance() + .getFieldValue("title") + .asString()); + } + + @Test + public void malformedPatchDocumentsAreRejectedWithoutChangingTheEntity() { + Thingifier thingifier = stringIdNotes(); + thingifier + .apiConfig() + .writeMethods() + .entities() + .patchCan(PARTIAL_JSON_UPDATE, JSON_MERGE_PATCH_RFC7396, JSON_PATCH_RFC6902); + createNote(thingifier, "one", "One", "Original"); + + ApiResponse partialArray = + patch(thingifier, "notes/one", "[{\"title\":\"Not an entity object\"}]"); + ApiResponse malformedPartial = patch(thingifier, "notes/one", "{\"title\":"); + ApiResponse malformedMerge = + patch(thingifier, "notes/one", "{\"title\":", JSON_MERGE_PATCH_RFC7396.mediaType()); + ApiResponse malformedJsonPatch = + patch(thingifier, "notes/one", "[{\"op\":", JSON_PATCH_RFC6902.mediaType()); + ApiResponse jsonPatchObject = + patch( + thingifier, + "notes/one", + "{\"op\":\"replace\",\"path\":\"/title\",\"value\":\"Wrong shape\"}", + JSON_PATCH_RFC6902.mediaType()); + + Assertions.assertEquals(400, partialArray.getStatusCode()); + Assertions.assertTrue( + partialArray + .getErrorMessages() + .contains("PATCH partial JSON update document must be an object")); + Assertions.assertEquals(400, malformedPartial.getStatusCode()); + Assertions.assertTrue( + malformedPartial.getErrorMessages().contains("Malformed JSON document")); + Assertions.assertEquals(400, malformedMerge.getStatusCode()); + Assertions.assertTrue( + malformedMerge.getErrorMessages().contains("Malformed JSON Merge Patch document")); + Assertions.assertEquals(400, malformedJsonPatch.getStatusCode()); + Assertions.assertTrue( + malformedJsonPatch.getErrorMessages().contains("Malformed JSON Patch document")); + Assertions.assertEquals(400, jsonPatchObject.getStatusCode()); + Assertions.assertTrue( + jsonPatchObject + .getErrorMessages() + .contains("JSON Patch document must be an array of operations")); + Assertions.assertEquals("One", currentTitle(thingifier, "one")); + Assertions.assertEquals("Original", currentDescription(thingifier, "one")); + } + + @Test + public void emptyPartialJsonPatchIsANoOpForTheTargetEntity() { + Thingifier thingifier = stringIdNotes(); + thingifier.apiConfig().writeMethods().entities().patchCan(PARTIAL_JSON_UPDATE); + createNote(thingifier, "one", "One", "Original"); + + ApiResponse response = patch(thingifier, "notes/one", ""); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals( + "One", response.getReturnedInstance().getFieldValue("title").asString()); + Assertions.assertEquals( + "Original", response.getReturnedInstance().getFieldValue("description").asString()); + Assertions.assertEquals("One", currentTitle(thingifier, "one")); + Assertions.assertEquals("Original", currentDescription(thingifier, "one")); + } + + @Test + public void patchRequiresPrimaryKeyRoutableEntities() { + Thingifier thingifier = new Thingifier(); + EntityDefinition log = thingifier.defineThing("log", "logs"); + log.addField(Field.is("title", FieldType.STRING).makeMandatory()); + thingifier.apiConfig().writeMethods().entities().patchCan(PARTIAL_JSON_UPDATE); + + ApiResponse response = patch(thingifier, "logs/anything", "{\"title\":\"No identity\"}"); + + Assertions.assertEquals(404, response.getStatusCode()); + Assertions.assertTrue( + response.getErrorMessages() + .contains("Entity log does not have a primary key defined")); + Assertions.assertEquals( + 0, + thingifier + .api() + .get("logs", new QueryFilterParams(), new HttpHeadersBlock()) + .getReturnedInstanceCollection() + .size()); + } + + @Test + public void rfcPatchStylesReturnNotFoundForMissingEntityAndDoNotCreateIt() { + Thingifier thingifier = stringIdNotes(); + thingifier + .apiConfig() + .writeMethods() + .entities() + .patchCan(JSON_MERGE_PATCH_RFC7396, JSON_PATCH_RFC6902); + + ApiResponse mergePatch = + patch( + thingifier, + "notes/missing", + "{\"title\":\"Should not exist\"}", + JSON_MERGE_PATCH_RFC7396.mediaType()); + ApiResponse jsonPatch = + patch( + thingifier, + "notes/missing", + "[{\"op\":\"replace\",\"path\":\"/title\",\"value\":\"Should not exist\"}]", + JSON_PATCH_RFC6902.mediaType()); + + Assertions.assertEquals(404, mergePatch.getStatusCode()); + Assertions.assertTrue( + mergePatch + .getErrorMessages() + .contains("No such note entity instance with id == missing found")); + Assertions.assertEquals(404, jsonPatch.getStatusCode()); + Assertions.assertTrue( + jsonPatch + .getErrorMessages() + .contains("No such note entity instance with id == missing found")); + Assertions.assertEquals( + 404, + thingifier + .api() + .get("notes/missing", new QueryFilterParams(), new HttpHeadersBlock()) + .getStatusCode()); + } + + @Test + public void jsonPatchWholeResourceResultMustStillBeAnEntityObject() { + Thingifier thingifier = stringIdNotes(); + thingifier.apiConfig().writeMethods().entities().patchCan(JSON_PATCH_RFC6902); + createNote(thingifier, "one", "One", "Original"); + + ApiResponse response = + patch( + thingifier, + "notes/one", + "[{\"op\":\"replace\",\"path\":\"\",\"value\":\"not an entity object\"}]", + JSON_PATCH_RFC6902.mediaType()); + + Assertions.assertEquals(422, response.getStatusCode()); + Assertions.assertTrue( + response.getErrorMessages() + .contains("PATCH result for entity resources must be an object")); + Assertions.assertEquals("One", currentTitle(thingifier, "one")); + Assertions.assertEquals("Original", currentDescription(thingifier, "one")); + } + + @Test + public void jsonPatchCanApplyOperationsAndFailedPatchIsAtomic() { + Thingifier thingifier = stringIdNotes(); + thingifier.apiConfig().writeMethods().entities().patchCan(JSON_PATCH_RFC6902); + createNote(thingifier, "one", "One", "Original"); + + ApiResponse failed = + patch( + thingifier, + "notes/one", + "[{\"op\":\"replace\",\"path\":\"/title\",\"value\":\"Changed\"}," + + "{\"op\":\"test\",\"path\":\"/description\",\"value\":\"Wrong\"}]", + JSON_PATCH_RFC6902.mediaType()); + + Assertions.assertEquals(409, failed.getStatusCode()); + Assertions.assertEquals( + "One", + thingifier + .api() + .get("notes/one", new QueryFilterParams(), new HttpHeadersBlock()) + .getReturnedInstance() + .getFieldValue("title") + .asString()); + + ApiResponse applied = + patch( + thingifier, + "notes/one", + "[{\"op\":\"replace\",\"path\":\"/title\",\"value\":\"Changed\"}," + + "{\"op\":\"remove\",\"path\":\"/description\"}]", + JSON_PATCH_RFC6902.mediaType()); + + Assertions.assertEquals(200, applied.getStatusCode()); + Assertions.assertEquals( + "Changed", applied.getReturnedInstance().getFieldValue("title").asString()); + Assertions.assertEquals( + "", applied.getReturnedInstance().getFieldValue("description").asString()); + } + @Test public void routeOverrideWinsOverEntityOverrideWhichWinsOverGlobalConfig() { Thingifier thingifier = stringIdNotes(); @@ -192,7 +512,7 @@ public void relationshipDeleteCanDisableDisconnect() { public void generatedDocsReflectConfiguredEntityPolicy() { Thingifier thingifier = autoIdNotes(); thingifier.apiConfig().writeMethods().entities().postCan(CREATE); - thingifier.apiConfig().writeMethods().entities().patchCan(UPDATE); + thingifier.apiConfig().writeMethods().entities().patchCan(PARTIAL_JSON_UPDATE); thingifier.apiConfig().writeMethods().entities().putCan(UPDATE); ApiRoutingDefinition definition = @@ -215,6 +535,49 @@ public void generatedDocsReflectConfiguredEntityPolicy() { Assertions.assertEquals( "OPTIONS, GET, HEAD, PUT, PATCH, DELETE", route(definition, RoutingVerb.OPTIONS, "notes/:id").headerValue()); + Assertions.assertEquals( + PARTIAL_JSON_UPDATE.mediaType(), + route(definition, RoutingVerb.OPTIONS, "notes/:id") + .getResponseHeaderValue("Accept-Patch")); + } + + @Test + public void routePatchStyleOverrideWinsOverEntityOverrideWhichWinsOverGlobalConfig() { + Thingifier routeOverride = stringIdNotes(); + routeOverride.apiConfig().writeMethods().entities().patchCan(PARTIAL_JSON_UPDATE); + routeOverride.apiSpec().entityPatchCan("/notes", JSON_MERGE_PATCH_RFC7396); + routeOverride + .apiSpec() + .route(RoutingVerb.PATCH, "/notes/{id}") + .entityPatchCan(JSON_PATCH_RFC6902); + createNote(routeOverride, "one", "One"); + + Assertions.assertEquals( + 415, patch(routeOverride, "notes/one", "{\"title\":\"Blocked\"}").getStatusCode()); + Assertions.assertEquals( + 200, + patch( + routeOverride, + "notes/one", + "[{\"op\":\"replace\",\"path\":\"/title\",\"value\":\"Allowed\"}]", + JSON_PATCH_RFC6902.mediaType()) + .getStatusCode()); + + Thingifier entityOverride = stringIdNotes(); + entityOverride.apiConfig().writeMethods().entities().patchCan(PARTIAL_JSON_UPDATE); + entityOverride.apiSpec().entityPatchCan("/notes", JSON_MERGE_PATCH_RFC7396); + createNote(entityOverride, "one", "One"); + + Assertions.assertEquals( + 415, patch(entityOverride, "notes/one", "{\"title\":\"Blocked\"}").getStatusCode()); + Assertions.assertEquals( + 200, + patch( + entityOverride, + "notes/one", + "{\"title\":\"Allowed\"}", + JSON_MERGE_PATCH_RFC7396.mediaType()) + .getStatusCode()); } @Test @@ -273,6 +636,7 @@ private Thingifier stringIdNotes() { EntityDefinition note = thingifier.defineThing("note", "notes"); note.addAsPrimaryKeyField(Field.is("id", FieldType.STRING)); note.addField(Field.is("title", FieldType.STRING).makeMandatory()); + note.addField(Field.is("description", FieldType.STRING)); return thingifier; } @@ -300,14 +664,21 @@ private Thingifier autoIdNotes() { private EntityInstance createNote( final Thingifier thingifier, final String id, final String title) { + return createNote(thingifier, id, title, null); + } + + private EntityInstance createNote( + final Thingifier thingifier, + final String id, + final String title, + final String description) { EntityDefinition note = thingifier.getDefinitionNamed("note"); - return thingifier - .getStore(EntityRelModel.DEFAULT_DATABASE_NAME) - .entities() - .create( - EntityInstanceDraft.forEntity(note) - .withField("id", id) - .withField("title", title)); + EntityInstanceDraft draft = + EntityInstanceDraft.forEntity(note).withField("id", id).withField("title", title); + if (description != null) { + draft.withField("description", description); + } + return thingifier.getStore(EntityRelModel.DEFAULT_DATABASE_NAME).entities().create(draft); } private EntityInstance createProject(final Thingifier thingifier, final String title) { @@ -335,7 +706,17 @@ private ApiResponse put(final Thingifier thingifier, final String path, final St } private ApiResponse patch(final Thingifier thingifier, final String path, final String body) { - return thingifier.api().patch(path, parser(thingifier, body), new HttpHeadersBlock()); + return patch(thingifier, path, body, PARTIAL_JSON_UPDATE.mediaType()); + } + + private ApiResponse patch( + final Thingifier thingifier, + final String path, + final String body, + final String contentType) { + HttpHeadersBlock headers = new HttpHeadersBlock(); + headers.put("Content-Type", contentType); + return thingifier.api().patch(path, body, headers); } private BodyParser parser(final Thingifier thingifier, final String body) { @@ -355,6 +736,29 @@ private String noteJson(final String id, final String title) { return "{\"id\":\"" + id + "\",\"title\":\"" + title + "\"}"; } + private int noteCount(final Thingifier thingifier) { + return thingifier + .api() + .get("notes", new QueryFilterParams(), new HttpHeadersBlock()) + .getReturnedInstanceCollection() + .size(); + } + + private String currentTitle(final Thingifier thingifier, final String id) { + return currentNote(thingifier, id).getFieldValue("title").asString(); + } + + private String currentDescription(final Thingifier thingifier, final String id) { + return currentNote(thingifier, id).getFieldValue("description").asString(); + } + + private EntityInstance currentNote(final Thingifier thingifier, final String id) { + return thingifier + .api() + .get("notes/" + id, new QueryFilterParams(), new HttpHeadersBlock()) + .getReturnedInstance(); + } + private RoutingDefinition route( final ApiRoutingDefinition definition, final RoutingVerb verb, final String url) { return definition.definitions().stream() 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 67881911..22245b63 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 @@ -1,5 +1,6 @@ package uk.co.compendiumdev.thingifier.apiconfig; +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.RelationshipWriteOperation.CONNECT_EXISTING; @@ -19,7 +20,7 @@ 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().patchOperations()); + Assertions.assertEquals(Set.of(), config.entities().patchUpdateStyles()); Assertions.assertEquals( Set.of(CREATE_AND_CONNECT, CONNECT_EXISTING), config.relationships().postOperations()); @@ -41,14 +42,15 @@ public void emptyOperationSetMeansMethodIsNotSupported() { public void configCanBeCopiedBetweenProfiles() { ThingifierApiConfig source = new ThingifierApiConfig(""); source.writeMethods().entities().postCan(CREATE); - source.writeMethods().entities().patchCan(UPDATE); + source.writeMethods().entities().patchCan(PARTIAL_JSON_UPDATE); source.writeMethods().relationships().postCan(CONNECT_EXISTING); ThingifierApiConfig target = new ThingifierApiConfig(""); target.setFrom(source); Assertions.assertEquals(Set.of(CREATE), target.writeMethods().entities().postOperations()); - Assertions.assertEquals(Set.of(UPDATE), target.writeMethods().entities().patchOperations()); + Assertions.assertEquals( + Set.of(PARTIAL_JSON_UPDATE), target.writeMethods().entities().patchUpdateStyles()); Assertions.assertEquals( Set.of(CONNECT_EXISTING), target.writeMethods().relationships().postOperations()); }