From e570df51218d29e6625a1d91c6f8d5b7f8642526 Mon Sep 17 00:00:00 2001 From: Justin Tay <49700559+justin-tay@users.noreply.github.com> Date: Tue, 30 May 2023 06:38:29 +0800 Subject: [PATCH] Add Atomic Operations Support (#2979) * Add atomic operations extension * Add test dsl. * Fix tests * Add operations to json api endpoint * Fix delete case with no data * Fix don't wrap runtimeexception in another runtimeexception * Add life cycle tests * Add tests for json api atomic operations mapper * Add tests for relationship * Add tests for invalid input * Add dsl and tests for local id * Fix tests * Adds ability to customize the object mapper used by the JsonApiMapper * Refactor naming * Add tests * Add standalone test * Handle media type parameters according to spec * Verify responses * Infer ref only for add and update resource. * Use id for remove resource * Add ResourceIT tests * Add lid to resource and tests for lid * Change ref to href * Set id when lid set * Add json api lid test case * Ref cannot be explicitly specified when creating resources * Validate ref and href mutually exclusive * Atomic operations result may contain meta * Add lid test * Validate ref cannot have both id and lid specified * Update error messages for consistency * Update ResourceIT lid test * Add dsl tests * Update error message * Add native hints * Fix test --- .../formatter/CSVExportFormatterTest.java | 2 +- .../formatter/JSONExportFormatterTest.java | 4 +- .../src/main/java/com/yahoo/elide/Elide.java | 128 ++- .../elide/core/exceptions/HttpStatus.java | 1 + .../JsonApiAtomicOperationsException.java | 32 + .../java/com/yahoo/elide/jsonapi/JsonApi.java | 29 + .../yahoo/elide/jsonapi/JsonApiMapper.java | 57 +- .../extensions/JsonApiAtomicOperations.java | 604 +++++++++++++ .../JsonApiAtomicOperationsMapper.java | 54 ++ .../JsonApiAtomicOperationsRequestScope.java | 75 ++ ...sonApiPatch.java => JsonApiJsonPatch.java} | 66 +- .../extensions/JsonApiJsonPatchMapper.java | 52 ++ ...java => JsonApiJsonPatchRequestScope.java} | 9 +- .../yahoo/elide/jsonapi/models/Operation.java | 141 +++ .../elide/jsonapi/models/Operations.java | 32 + .../com/yahoo/elide/jsonapi/models/Ref.java | 69 ++ .../yahoo/elide/jsonapi/models/Resource.java | 28 +- .../yahoo/elide/jsonapi/models/Result.java | 38 + .../yahoo/elide/jsonapi/models/Results.java | 32 + .../jsonapi/resources/JsonApiEndpoint.java | 36 +- .../elide-core/reflect-config.json | 50 +- .../elide/core/PersistentResourceTest.java | 8 +- .../InMemoryStoreTransactionTest.java | 1 + .../core/exceptions/ErrorMapperTest.java | 4 +- .../elide/core/lifecycle/LifeCycleTest.java | 816 +++++++++++++----- .../elide/core/utils/ClassScannerTest.java | 6 +- .../JsonApiAtomicOperationsMapperTest.java | 142 +++ .../JsonApiAtomicOperationsTest.java | 651 ++++++++++++++ .../elide/jsonapi/models/OperationTest.java | 50 ++ .../elide/jsonapi/models/OperationsTest.java | 45 + elide-core/src/test/java/example/Person.java | 34 + .../com/yahoo/elide/tests/ResourceIT.java | 434 ++++++++++ .../atomicOpAddRelationships.2.json | 30 + .../ResourceIT/atomicOpAddRelationships.json | 7 + .../atomicOpAddRelationships.req.json | 18 + .../ResourceIT/atomicOpAddUpdate.json | 53 ++ .../ResourceIT/atomicOpAddUpdate.req.json | 54 ++ .../ResourceIT/atomicOpAddUpdateLid.json | 53 ++ .../ResourceIT/atomicOpAddUpdateLid.req.json | 53 ++ .../ResourceIT/atomicOpBadDelete.json | 1 + .../ResourceIT/atomicOpBadDelete.req.json | 8 + .../resources/ResourceIT/atomicOpBadId.json | 4 + .../ResourceIT/atomicOpBadId.req.json | 58 ++ .../ResourceIT/atomicOpCheckWithError.json | 17 + .../atomicOpCheckWithError.req.json | 23 + .../atomicOpCreateAndRemoveParent.json | 24 + .../atomicOpCreateAndRemoveParent.req.json | 23 + .../atomicOpCreateChildRelateExisting.json | 26 + ...atomicOpCreateChildRelateExisting.req.json | 22 + .../ResourceIT/atomicOpCreateDependent.json | 48 ++ .../atomicOpCreateDependent.req.json | 33 + .../ResourceIT/atomicOpDeferredOnCreate.json | 7 + .../atomicOpDeferredOnCreate.req.json | 28 + .../atomicOpInvalidMissingId.req.json | 29 + .../atomicOpInvalidMissingPath.req.json | 29 + .../ResourceIT/atomicOpIssue608.req.json | 44 + .../ResourceIT/atomicOpIssue608.resp.json | 53 ++ .../atomicOpNestedPatchCreate.req.json | 45 + .../atomicOpNestedPatchCreate.resp.json | 77 ++ .../ResourceIT/atomicOpNoCommit.req.json | 15 + .../atomicOpNoReadPermForNew.req.json | 31 + .../atomicOpNoReadPermForNew.resp.json | 40 + .../ResourceIT/atomicOpNullOp.req.json | 15 + .../ResourceIT/atomicOpRemoveObject.1.json | 21 + .../atomicOpRemoveObject.1.req.json | 15 + .../ResourceIT/atomicOpRemoveObject.2.json | 7 + .../atomicOpRemoveObject.2.req.json | 12 + .../atomicOpRemoveObject.direct.json | 17 + .../atomicOpRemoveSingleRelationship.1.json | 7 + .../atomicOpRemoveSingleRelationship.2.json | 22 + .../atomicOpRemoveSingleRelationship.req.json | 14 + ...cOpReplaceAttributesAndRelationship.2.json | 17 + ...micOpReplaceAttributesAndRelationship.json | 7 + ...pReplaceAttributesAndRelationship.req.json | 20 + .../ResourceIT/atomicOpTestAddRoot.1.json | 21 + .../ResourceIT/atomicOpTestAddRoot.2.json | 17 + .../ResourceIT/atomicOpTestAddRoot.req.json | 15 + ...omicOpUpdateChildRelationToExisting.1.json | 7 + ...omicOpUpdateChildRelationToExisting.2.json | 22 + ...icOpUpdateChildRelationToExisting.req.json | 22 + .../atomicOpUpdateRelationshipDirect.1.json | 7 + .../atomicOpUpdateRelationshipDirect.2.json | 26 + .../atomicOpUpdateRelationshipDirect.req.json | 18 + .../spring/config/ElideAutoConfiguration.java | 18 +- .../spring/controllers/JsonApiController.java | 49 +- .../spring/jackson/ObjectMapperBuilder.java | 18 + .../models/jpa/ArtifactMaintainer.java | 35 + .../example/models/jpa/ArtifactProduct.java | 4 + .../test/java/example/models/jpa/Book.java | 33 + .../test/java/example/models/jpa/Person.java | 34 + .../java/example/models/jpa/Publisher.java | 56 ++ .../test/java/example/tests/AsyncTest.java | 2 +- .../java/example/tests/ControllerTest.java | 654 +++++++++++++- .../tests/DisableAggStoreControllerTest.java | 3 +- .../DisableMetaDataStoreControllerTest.java | 2 +- .../java/example/ElideStandaloneTest.java | 71 ++ .../yahoo/elide/test/jsonapi/JsonApiDSL.java | 133 +++ .../jsonapi/elements/AtomicOperation.java | 58 ++ .../jsonapi/elements/AtomicOperationCode.java | 13 + .../jsonapi/elements/AtomicOperations.java | 43 + .../elide/test/jsonapi/elements/Lid.java | 20 + .../elide/test/jsonapi/elements/Ref.java | 63 ++ .../test/jsonapi/elements/Relationship.java | 20 + .../elide/test/jsonapi/elements/Resource.java | 22 + .../jsonapi/elements/ResourceLinkage.java | 11 + .../elide/test/jsonapi/JsonApiDSLTest.java | 193 +++++ 106 files changed, 6253 insertions(+), 314 deletions(-) create mode 100644 elide-core/src/main/java/com/yahoo/elide/core/exceptions/JsonApiAtomicOperationsException.java create mode 100644 elide-core/src/main/java/com/yahoo/elide/jsonapi/JsonApi.java create mode 100644 elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiAtomicOperations.java create mode 100644 elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiAtomicOperationsMapper.java create mode 100644 elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiAtomicOperationsRequestScope.java rename elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/{JsonApiPatch.java => JsonApiJsonPatch.java} (85%) create mode 100644 elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiJsonPatchMapper.java rename elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/{PatchRequestScope.java => JsonApiJsonPatchRequestScope.java} (87%) create mode 100644 elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Operation.java create mode 100644 elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Operations.java create mode 100644 elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Ref.java create mode 100644 elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Result.java create mode 100644 elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Results.java create mode 100644 elide-core/src/test/java/com/yahoo/elide/jsonapi/extensions/JsonApiAtomicOperationsMapperTest.java create mode 100644 elide-core/src/test/java/com/yahoo/elide/jsonapi/extensions/JsonApiAtomicOperationsTest.java create mode 100644 elide-core/src/test/java/com/yahoo/elide/jsonapi/models/OperationTest.java create mode 100644 elide-core/src/test/java/com/yahoo/elide/jsonapi/models/OperationsTest.java create mode 100644 elide-core/src/test/java/example/Person.java create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddRelationships.2.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddRelationships.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddRelationships.req.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddUpdate.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddUpdate.req.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddUpdateLid.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddUpdateLid.req.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpBadDelete.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpBadDelete.req.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpBadId.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpBadId.req.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpCheckWithError.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpCheckWithError.req.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpCreateAndRemoveParent.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpCreateAndRemoveParent.req.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpCreateChildRelateExisting.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpCreateChildRelateExisting.req.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpCreateDependent.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpCreateDependent.req.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpDeferredOnCreate.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpDeferredOnCreate.req.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpInvalidMissingId.req.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpInvalidMissingPath.req.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpIssue608.req.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpIssue608.resp.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpNestedPatchCreate.req.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpNestedPatchCreate.resp.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpNoCommit.req.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpNoReadPermForNew.req.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpNoReadPermForNew.resp.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpNullOp.req.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveObject.1.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveObject.1.req.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveObject.2.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveObject.2.req.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveObject.direct.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveSingleRelationship.1.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveSingleRelationship.2.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveSingleRelationship.req.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpReplaceAttributesAndRelationship.2.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpReplaceAttributesAndRelationship.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpReplaceAttributesAndRelationship.req.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpTestAddRoot.1.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpTestAddRoot.2.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpTestAddRoot.req.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpUpdateChildRelationToExisting.1.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpUpdateChildRelationToExisting.2.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpUpdateChildRelationToExisting.req.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpUpdateRelationshipDirect.1.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpUpdateRelationshipDirect.2.json create mode 100644 elide-integration-tests/src/test/resources/ResourceIT/atomicOpUpdateRelationshipDirect.req.json create mode 100644 elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/jackson/ObjectMapperBuilder.java create mode 100644 elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/ArtifactMaintainer.java create mode 100644 elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/Book.java create mode 100644 elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/Person.java create mode 100644 elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/Publisher.java create mode 100644 elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/AtomicOperation.java create mode 100644 elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/AtomicOperationCode.java create mode 100644 elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/AtomicOperations.java create mode 100644 elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/Lid.java create mode 100644 elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/Ref.java create mode 100644 elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/Relationship.java diff --git a/elide-async/src/test/java/com/yahoo/elide/async/export/formatter/CSVExportFormatterTest.java b/elide-async/src/test/java/com/yahoo/elide/async/export/formatter/CSVExportFormatterTest.java index ec69595e02..59ad7909ba 100644 --- a/elide-async/src/test/java/com/yahoo/elide/async/export/formatter/CSVExportFormatterTest.java +++ b/elide-async/src/test/java/com/yahoo/elide/async/export/formatter/CSVExportFormatterTest.java @@ -86,7 +86,7 @@ public void testResourceToCSV() { resourceAttributes.put("queryType", queryObj.getQueryType()); resourceAttributes.put("createdOn", queryObj.getCreatedOn()); - Resource resource = new Resource("tableExport", "0", resourceAttributes, null, null, null); + Resource resource = new Resource("tableExport", "0", null, resourceAttributes, null, null, null); PersistentResource persistentResource = mock(PersistentResource.class); when(persistentResource.getObject()).thenReturn(queryObj); diff --git a/elide-async/src/test/java/com/yahoo/elide/async/export/formatter/JSONExportFormatterTest.java b/elide-async/src/test/java/com/yahoo/elide/async/export/formatter/JSONExportFormatterTest.java index 3b8786344d..6ab096aa93 100644 --- a/elide-async/src/test/java/com/yahoo/elide/async/export/formatter/JSONExportFormatterTest.java +++ b/elide-async/src/test/java/com/yahoo/elide/async/export/formatter/JSONExportFormatterTest.java @@ -84,7 +84,7 @@ public void testFormat() { resourceAttributes.put("queryType", queryObj.getQueryType()); resourceAttributes.put("createdOn", queryObj.getCreatedOn()); - Resource resource = new Resource("tableExport", "0", resourceAttributes, null, null, null); + Resource resource = new Resource("tableExport", "0", null, resourceAttributes, null, null, null); PersistentResource persistentResource = mock(PersistentResource.class); when(persistentResource.getObject()).thenReturn(queryObj); when(persistentResource.getRequestScope()).thenReturn(scope); @@ -115,7 +115,7 @@ public void testResourceToJSON() { resourceAttributes.put("query", "{ tableExport { edges { node { query queryType} } } }"); resourceAttributes.put("queryType", QueryType.GRAPHQL_V1_0); - Resource resource = new Resource("tableExport", "0", resourceAttributes, null, null, null); + Resource resource = new Resource("tableExport", "0", null, resourceAttributes, null, null, null); PersistentResource persistentResource = mock(PersistentResource.class); when(persistentResource.getObject()).thenReturn(queryObj); when(persistentResource.getRequestScope()).thenReturn(scope); diff --git a/elide-core/src/main/java/com/yahoo/elide/Elide.java b/elide-core/src/main/java/com/yahoo/elide/Elide.java index fd4c4d9f2f..c12915de15 100644 --- a/elide-core/src/main/java/com/yahoo/elide/Elide.java +++ b/elide-core/src/main/java/com/yahoo/elide/Elide.java @@ -21,6 +21,7 @@ import com.yahoo.elide.core.exceptions.HttpStatusException; import com.yahoo.elide.core.exceptions.InternalServerErrorException; import com.yahoo.elide.core.exceptions.InvalidURLException; +import com.yahoo.elide.core.exceptions.JsonApiAtomicOperationsException; import com.yahoo.elide.core.exceptions.JsonPatchExtensionException; import com.yahoo.elide.core.exceptions.TransactionException; import com.yahoo.elide.core.security.User; @@ -29,9 +30,12 @@ import com.yahoo.elide.core.utils.coerce.converters.ElideTypeConverter; import com.yahoo.elide.core.utils.coerce.converters.Serde; import com.yahoo.elide.jsonapi.EntityProjectionMaker; +import com.yahoo.elide.jsonapi.JsonApi; import com.yahoo.elide.jsonapi.JsonApiMapper; -import com.yahoo.elide.jsonapi.extensions.JsonApiPatch; -import com.yahoo.elide.jsonapi.extensions.PatchRequestScope; +import com.yahoo.elide.jsonapi.extensions.JsonApiAtomicOperations; +import com.yahoo.elide.jsonapi.extensions.JsonApiAtomicOperationsRequestScope; +import com.yahoo.elide.jsonapi.extensions.JsonApiJsonPatch; +import com.yahoo.elide.jsonapi.extensions.JsonApiJsonPatchRequestScope; import com.yahoo.elide.jsonapi.models.JsonApiDocument; import com.yahoo.elide.jsonapi.parser.BaseVisitor; import com.yahoo.elide.jsonapi.parser.DeleteVisitor; @@ -73,9 +77,9 @@ */ @Slf4j public class Elide { - public static final String JSONAPI_CONTENT_TYPE = "application/vnd.api+json"; + public static final String JSONAPI_CONTENT_TYPE = JsonApi.MEDIA_TYPE; public static final String JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION = - "application/vnd.api+json; ext=jsonpatch"; + JsonApi.JsonPatch.MEDIA_TYPE; @Getter private final ElideSettings elideSettings; @Getter private final AuditLogger auditLogger; @@ -390,13 +394,13 @@ public ElideResponse patch(String baseUrlEndPoint, String contentType, String ac String apiVersion, UUID requestId) { Handler handler; - if (JsonApiPatch.isPatchExtension(contentType) && JsonApiPatch.isPatchExtension(accept)) { + if (JsonApiJsonPatch.isPatchExtension(contentType) && JsonApiJsonPatch.isPatchExtension(accept)) { handler = (tx, user) -> { - PatchRequestScope requestScope = new PatchRequestScope(baseUrlEndPoint, path, apiVersion, tx, - user, requestId, queryParams, requestHeaders, elideSettings); + JsonApiJsonPatchRequestScope requestScope = new JsonApiJsonPatchRequestScope(baseUrlEndPoint, path, + apiVersion, tx, user, requestId, queryParams, requestHeaders, elideSettings); try { Supplier> responder = - JsonApiPatch.processJsonPatch(dataStore, path, jsonApiDocument, requestScope); + JsonApiJsonPatch.processJsonPatch(dataStore, path, jsonApiDocument, requestScope); return new HandlerResult(requestScope, responder); } catch (RuntimeException e) { return new HandlerResult(requestScope, e); @@ -482,6 +486,87 @@ public ElideResponse delete(String baseUrlEndPoint, String path, String jsonApiD }); } + /** + * Handle operations for the Atomic Operations extension. + * + * @param baseUrlEndPoint base URL with prefix endpoint + * @param contentType the content type + * @param accept the accept + * @param path the path + * @param jsonApiDocument the json api document + * @param opaqueUser the opaque user + * @param apiVersion the API version + * @return Elide response object + */ + public ElideResponse operations(String baseUrlEndPoint, String contentType, String accept, + String path, String jsonApiDocument, + User opaqueUser, String apiVersion) { + return operations(baseUrlEndPoint, contentType, accept, path, jsonApiDocument, + null, opaqueUser, apiVersion, UUID.randomUUID()); + } + + /** + * Handle operations for the Atomic Operations extension. + * + * @param baseUrlEndPoint base URL with prefix endpoint + * @param contentType the content type + * @param accept the accept + * @param path the path + * @param jsonApiDocument the json api document + * @param queryParams the query params + * @param opaqueUser the opaque user + * @param apiVersion the API version + * @param requestId the request ID + * @return Elide response object + */ + public ElideResponse operations(String baseUrlEndPoint, String contentType, String accept, String path, + String jsonApiDocument, MultivaluedMap queryParams, User opaqueUser, String apiVersion, + UUID requestId) { + return operations(baseUrlEndPoint, contentType, accept, path, jsonApiDocument, queryParams, null, opaqueUser, + apiVersion, requestId); + } + + /** + * Handle operations for the Atomic Operations extension. + * + * @param baseUrlEndPoint base URL with prefix endpoint + * @param contentType the content type + * @param accept the accept + * @param path the path + * @param jsonApiDocument the json api document + * @param queryParams the query params + * @param opaqueUser the opaque user + * @param apiVersion the API version + * @param requestId the request ID + * @return Elide response object + * @return + */ + public ElideResponse operations(String baseUrlEndPoint, String contentType, String accept, String path, + String jsonApiDocument, MultivaluedMap queryParams, + Map> requestHeaders, User opaqueUser, String apiVersion, UUID requestId) { + + Handler handler; + if (JsonApiAtomicOperations.isAtomicOperationsExtension(contentType) + && JsonApiAtomicOperations.isAtomicOperationsExtension(accept)) { + handler = (tx, user) -> { + JsonApiAtomicOperationsRequestScope requestScope = new JsonApiAtomicOperationsRequestScope( + baseUrlEndPoint, path, + apiVersion, tx, user, requestId, queryParams, requestHeaders, elideSettings); + try { + Supplier> responder = JsonApiAtomicOperations + .processAtomicOperations(dataStore, path, jsonApiDocument, requestScope); + return new HandlerResult(requestScope, responder); + } catch (RuntimeException e) { + return new HandlerResult(requestScope, e); + } + }; + } else { + return new ElideResponse(HttpStatus.SC_UNSUPPORTED_MEDIA_TYPE, "Unsupported Media Type"); + } + + return handleRequest(false, opaqueUser, dataStore::beginTransaction, requestId, handler); + } + public HandlerResult visit(String path, RequestScope requestScope, BaseVisitor visitor) { try { Supplier> responder = visitor.visit(JsonApiParser.parse(path)); @@ -559,8 +644,7 @@ private ElideResponse handleNonRuntimeException(Exception error, boolean isVerbo return buildErrorResponse(mappedException, isVerbose); } - if (error instanceof JacksonException) { - JacksonException jacksonException = (JacksonException) error; + if (error instanceof JacksonException jacksonException) { String message = (jacksonException.getLocation() != null && jacksonException.getLocation().getSourceRef() != null) ? error.getMessage() //This will leak Java class info if the location isn't known. @@ -589,34 +673,34 @@ private ElideResponse handleRuntimeException(RuntimeException error, boolean isV throw error; } - if (error instanceof ForbiddenAccessException) { - ForbiddenAccessException e = (ForbiddenAccessException) error; + if (error instanceof ForbiddenAccessException e) { if (log.isDebugEnabled()) { log.debug("{}", e.getLoggedMessage()); } return buildErrorResponse(e, isVerbose); } - if (error instanceof JsonPatchExtensionException) { - JsonPatchExtensionException e = (JsonPatchExtensionException) error; - log.debug("JSON patch extension exception caught", e); + if (error instanceof JsonPatchExtensionException e) { + log.debug("JSON API Json Patch extension exception caught", e); return buildErrorResponse(e, isVerbose); } - if (error instanceof HttpStatusException) { - HttpStatusException e = (HttpStatusException) error; + if (error instanceof JsonApiAtomicOperationsException e) { + log.debug("JSON API Atomic Operations extension exception caught", e); + return buildErrorResponse(e, isVerbose); + } + + if (error instanceof HttpStatusException e) { log.debug("Caught HTTP status exception", e); return buildErrorResponse(e, isVerbose); } - if (error instanceof ParseCancellationException) { - ParseCancellationException e = (ParseCancellationException) error; + if (error instanceof ParseCancellationException e) { log.debug("Parse cancellation exception uncaught by Elide (i.e. invalid URL)", e); return buildErrorResponse(new InvalidURLException(e), isVerbose); } - if (error instanceof ConstraintViolationException) { - ConstraintViolationException e = (ConstraintViolationException) error; + if (error instanceof ConstraintViolationException e) { log.debug("Constraint violation exception caught", e); String message = "Constraint violation"; final ErrorObjects.ErrorObjectsBuilder errorObjectsBuilder = ErrorObjects.builder(); @@ -637,7 +721,7 @@ private ElideResponse handleRuntimeException(RuntimeException error, boolean isV } log.error("Error or exception uncaught by Elide", error); - throw new RuntimeException(error); + throw error; } public CustomErrorException mapError(Exception error) { diff --git a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/HttpStatus.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/HttpStatus.java index 7d94083993..7a90959956 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/HttpStatus.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/HttpStatus.java @@ -18,6 +18,7 @@ public class HttpStatus { public static final int SC_NOT_FOUND = 404; public static final int SC_METHOD_NOT_ALLOWED = 405; public static final int SC_TIMEOUT = 408; + public static final int SC_UNSUPPORTED_MEDIA_TYPE = 415; public static final int SC_LOCKED = 423; public static final int SC_INTERNAL_SERVER_ERROR = 500; } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/JsonApiAtomicOperationsException.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/JsonApiAtomicOperationsException.java new file mode 100644 index 0000000000..123e835063 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/JsonApiAtomicOperationsException.java @@ -0,0 +1,32 @@ +/* + * Copyright 2016, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.core.exceptions; + +import com.fasterxml.jackson.databind.JsonNode; +import org.apache.commons.lang3.tuple.Pair; + +/** + * Exception describing error caused from JSON API Atomic Extension request. + */ +public class JsonApiAtomicOperationsException extends HttpStatusException { + private static final long serialVersionUID = 1L; + private final Pair response; + + public JsonApiAtomicOperationsException(int status, final JsonNode errorNode) { + super(status, ""); + response = Pair.of(status, errorNode); + } + + @Override + public Pair getErrorResponse() { + return response; + } + + @Override + public Pair getVerboseErrorResponse() { + return response; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/JsonApi.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/JsonApi.java new file mode 100644 index 0000000000..d95f6d0e6a --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/JsonApi.java @@ -0,0 +1,29 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.jsonapi; + +/** + * JSON:API. + */ +public class JsonApi { + private JsonApi() { + } + public static final String MEDIA_TYPE = "application/vnd.api+json"; + + public static class JsonPatch { + private JsonPatch() { + } + + public static final String MEDIA_TYPE = "application/vnd.api+json; ext=jsonpatch"; + } + + public static class AtomicOperations { + private AtomicOperations() { + } + + public static final String MEDIA_TYPE = "application/vnd.api+json; ext=\"https://jsonapi.org/ext/atomic\""; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/JsonApiMapper.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/JsonApiMapper.java index 7eea7df022..39e90706bf 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/JsonApiMapper.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/JsonApiMapper.java @@ -5,28 +5,28 @@ */ package com.yahoo.elide.jsonapi; +import com.yahoo.elide.jsonapi.extensions.JsonApiAtomicOperationsMapper; +import com.yahoo.elide.jsonapi.extensions.JsonApiJsonPatchMapper; import com.yahoo.elide.jsonapi.models.JsonApiDocument; -import com.yahoo.elide.jsonapi.models.Patch; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.JsonNodeFactory; import java.io.IOException; -import java.util.List; /** * Serializer/Deserializer for JSON API. */ public class JsonApiMapper { - private final ObjectMapper mapper; + protected final ObjectMapper mapper; + protected final JsonApiJsonPatchMapper jsonPatchMapper; + protected final JsonApiAtomicOperationsMapper atomicOperationsMapper; /** * Instantiates a new Json Api Mapper. */ public JsonApiMapper() { - this.mapper = new ObjectMapper(); - mapper.registerModule(JsonApiSerializer.getModule()); + this(new ObjectMapper()); } /** @@ -35,8 +35,22 @@ public JsonApiMapper() { * @param mapper Custom object mapper to use internally for serializing/deserializing */ public JsonApiMapper(ObjectMapper mapper) { + this(mapper, new JsonApiJsonPatchMapper(mapper), new JsonApiAtomicOperationsMapper(mapper)); + } + + /** + * Instantiates a new Json Api Mapper. + * + * @param mapper Custom object mapper to use internally for serializing/deserializing + * @param jsonPatchMapper the mapper for the JSON Patch extension + * @param atomicOperationsMapper the mapper for the Atomic Operations extension + */ + public JsonApiMapper(ObjectMapper mapper, JsonApiJsonPatchMapper jsonPatchMapper, + JsonApiAtomicOperationsMapper atomicOperationsMapper) { this.mapper = mapper; - mapper.registerModule(JsonApiSerializer.getModule()); + this.mapper.registerModule(JsonApiSerializer.getModule()); + this.jsonPatchMapper = jsonPatchMapper; + this.atomicOperationsMapper = atomicOperationsMapper; } /** @@ -85,34 +99,29 @@ public JsonApiDocument readJsonApiDocument(JsonNode node) throws IOException { } /** - * Read json api patch ext value. + * Gets the Jackson ObjectMapper. * - * @param value the value - * @return the json api document - * @throws JsonProcessingException the json processing exception + * @return the Jackson ObjectMapper */ - public JsonApiDocument readJsonApiPatchExtValue(JsonNode value) throws JsonProcessingException { - JsonNode data = JsonNodeFactory.instance.objectNode().set("data", value); - return mapper.treeToValue(data, JsonApiDocument.class); + public ObjectMapper getObjectMapper() { + return mapper; } /** - * Read json api patch ext doc. + * Gets the mapper for the JSON Patch extension. * - * @param doc the doc - * @return the list - * @throws IOException the iO exception + * @return the mapper for the JSON Patch extension. */ - public List readJsonApiPatchExtDoc(String doc) throws IOException { - return mapper.readValue(doc, mapper.getTypeFactory().constructCollectionType(List.class, Patch.class)); + public JsonApiJsonPatchMapper forJsonPatch() { + return this.jsonPatchMapper; } /** - * Gets object OBJECT_MAPPER. + * Gets the mapper for the Atomic Operations extension. * - * @return the object OBJECT_MAPPER + * @return the mapper for the Atomic Operations extension. */ - public ObjectMapper getObjectMapper() { - return mapper; + public JsonApiAtomicOperationsMapper forAtomicOperations() { + return this.atomicOperationsMapper; } } diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiAtomicOperations.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiAtomicOperations.java new file mode 100644 index 0000000000..0af8112751 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiAtomicOperations.java @@ -0,0 +1,604 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.jsonapi.extensions; + +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.exceptions.HttpStatus; +import com.yahoo.elide.core.exceptions.HttpStatusException; +import com.yahoo.elide.core.exceptions.InvalidEntityBodyException; +import com.yahoo.elide.core.exceptions.JsonApiAtomicOperationsException; +import com.yahoo.elide.jsonapi.JsonApiMapper; +import com.yahoo.elide.jsonapi.models.Data; +import com.yahoo.elide.jsonapi.models.JsonApiDocument; +import com.yahoo.elide.jsonapi.models.Operation; +import com.yahoo.elide.jsonapi.models.Operation.OperationCode; +import com.yahoo.elide.jsonapi.models.Operations; +import com.yahoo.elide.jsonapi.models.Ref; +import com.yahoo.elide.jsonapi.models.Resource; +import com.yahoo.elide.jsonapi.models.Result; +import com.yahoo.elide.jsonapi.models.Results; +import com.yahoo.elide.jsonapi.parser.DeleteVisitor; +import com.yahoo.elide.jsonapi.parser.JsonApiParser; +import com.yahoo.elide.jsonapi.parser.PatchVisitor; +import com.yahoo.elide.jsonapi.parser.PostVisitor; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.commons.collections4.IterableUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; +import org.owasp.encoder.Encode; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; + +/** + * JSON API Atomic Operations extension. + * @see Atomic Operations + */ +public class JsonApiAtomicOperations { + public static final String EXTENSION = "https://jsonapi.org/ext/atomic"; + + private static class OperationAction { + public final Operation operation; + + // Failure + public HttpStatusException cause; + + // Post Processing + public boolean isPostProcessing; + public JsonApiDocument doc; + public String path; + + public OperationAction(Operation operation) { + this.operation = operation; + this.cause = null; + } + + public void postProcess(JsonApiAtomicOperationsRequestScope requestScope) { + if (isPostProcessing) { + try { + // Only update relationships + clearAllExceptRelationships(doc); + PatchVisitor visitor = new PatchVisitor( + new JsonApiAtomicOperationsRequestScope(path, doc, requestScope)); + visitor.visit(JsonApiParser.parse(path)); + } catch (HttpStatusException e) { + cause = e; + throw e; + } + } + } + } + + private final List actions; + private final String rootUri; + + private static final ObjectNode ERR_NODE_ERR_IN_SUBSEQUENT_OPERATION; + private static final ObjectNode ERR_NODE_OPERATION_NOT_RUN; + + static { + ERR_NODE_OPERATION_NOT_RUN = JsonNodeFactory.instance.objectNode(); + ERR_NODE_OPERATION_NOT_RUN.set("detail", + JsonNodeFactory.instance.textNode("Operation not executed. Terminated by earlier failure.")); + + ERR_NODE_ERR_IN_SUBSEQUENT_OPERATION = JsonNodeFactory.instance.objectNode(); + ERR_NODE_ERR_IN_SUBSEQUENT_OPERATION.set("detail", + JsonNodeFactory.instance.textNode("Subsequent operation failed.")); + } + + /** + * Process JSON Atomic Operations. + * + * @param dataStore the dataStore + * @param uri the uri + * @param operationsDoc the operations doc + * @param requestScope request scope + * @return pair + */ + public static Supplier> processAtomicOperations(DataStore dataStore, + String uri, + String operationsDoc, + JsonApiAtomicOperationsRequestScope requestScope) { + List actions; + try { + Operations operations = requestScope.getMapper().forAtomicOperations().readDoc(operationsDoc); + actions = operations.getOperations(); + } catch (InvalidFormatException e) { + if (e.getMessage() != null + && e.getMessage().contains("$OperationCode")) { + // Invalid op code results in a format error as it is an enum + throw new InvalidEntityBodyException( + "Invalid Atomic Operations extension operation code:" + + e.getValue()); + } else { + throw new InvalidEntityBodyException(operationsDoc); + } + } catch (IOException e) { + throw new InvalidEntityBodyException(operationsDoc); + } + JsonApiAtomicOperations processor = new JsonApiAtomicOperations(dataStore, actions, uri, requestScope); + return processor.processActions(requestScope); + } + + /** + * Constructor. + * + * @param dataStore Data Store + * @param actions List of patch actions + * @param rootUri root URI + */ + private JsonApiAtomicOperations(DataStore dataStore, + List actions, + String rootUri, + RequestScope requestScope) { + this.actions = actions.stream().map(OperationAction::new).toList(); + this.rootUri = rootUri; + } + + /** + * Process atomic operations actions. + * + * @return Pair (return code, JsonNode) + */ + private Supplier> processActions(JsonApiAtomicOperationsRequestScope requestScope) { + try { + List>> results = handleActions(requestScope); + + postProcessRelationships(requestScope); + + return () -> { + try { + return Pair.of(HttpStatus.SC_OK, + mergeResponse(results, requestScope.getMapper())); + } catch (HttpStatusException e) { + throwErrorResponse(); + // NOTE: This should never be called. throwErrorResponse should _always_ throw an exception + return null; + } + }; + } catch (HttpStatusException e) { + throwErrorResponse(); + // NOTE: This should never be called. throwErrorResponse should _always_ throw an exception + return () -> null; + } + } + + protected String getFullPath(Ref ref, Operation operation) { + if (ref != null) { + StringBuilder fullPathBuilder = new StringBuilder(); + if (ref.getType() == null) { + throw new InvalidEntityBodyException( + "Atomic Operations extension ref must specify the type member."); + } + fullPathBuilder.append(ref.getType()); + + // Only relationship operations or resource update or remove operation should have the id + if (ref.getRelationship() != null || OperationCode.UPDATE.equals(operation.getOperationCode()) + || OperationCode.REMOVE.equals(operation.getOperationCode())) { + if (ref.getId() != null) { + fullPathBuilder.append("/"); + fullPathBuilder.append(ref.getId()); + } else if (ref.getLid() != null) { + fullPathBuilder.append("/"); + fullPathBuilder.append(ref.getLid()); + } + } + if (ref.getRelationship() != null) { + fullPathBuilder.append("/"); + fullPathBuilder.append("relationships"); + fullPathBuilder.append("/"); + fullPathBuilder.append(ref.getRelationship()); + } + return fullPathBuilder.toString(); + } + return null; + + } + + /** + * Performs basic validation that the Operation is specified correctly. + * + * @param operation the operation to validate + */ + private void validateOperation(Operation operation) { + if (operation == null) { + throw new InvalidEntityBodyException("Atomic Operations extension operation must be specified."); + } + if (operation.getOperationCode() == null) { + throw new InvalidEntityBodyException( + "Atomic Operations extension operation code must be specified."); + } + String href = operation.getHref(); + Ref ref = operation.getRef(); + + if (href != null && ref != null) { + throw new InvalidEntityBodyException( + "Atomic Operations extension operation cannot contain both ref and href members."); + } + if (ref != null && ref.getLid() != null && ref.getId() != null) { + throw new InvalidEntityBodyException( + "Atomic Operations extension ref cannot contain both id and lid members."); + } + } + + /** + * Handle a atomic operations action. + * + * @param requestScope outer request scope + * @return List of responders + */ + private List>> handleActions( + JsonApiAtomicOperationsRequestScope requestScope) { + return actions.stream().map(action -> { + Supplier> result; + try { + Operation operation = action.operation; + validateOperation(operation); + JsonNode data = operation.getData(); + String href = operation.getHref(); + Ref ref = operation.getRef(); + String fullPath = href; + boolean refSpecified = ref != null; + + if (fullPath == null) { + if (ref == null) { + ref = inferRef(requestScope.getMapper(), operation); + } + fullPath = getFullPath(ref, operation); + } + if (fullPath == null) { + throw new InvalidEntityBodyException( + "Atomic Operations extension operation requires either ref or href members to be specified." + ); + } else if (refSpecified && Operation.OperationCode.ADD.equals(operation.getOperationCode()) + && isResourceOperation(fullPath)) { + throw new InvalidEntityBodyException( + "Atomic Operations extension add resource operation may only specify the href member."); + } + + switch (operation.getOperationCode()) { + case ADD: + result = handleAddOp(fullPath, data, requestScope, action); + break; + case UPDATE: + result = handleUpdateOp(fullPath, data, requestScope, action); + break; + case REMOVE: + result = handleRemoveOp(fullPath, data, requestScope); + break; + default: + throw new InvalidEntityBodyException( + "Invalid Atomic Operations extension operation code:" + + operation.getOperationCode()); + } + return result; + } catch (HttpStatusException e) { + action.cause = e; + throw e; + } + }).toList(); + } + + /** + * Infer ref using the data for operations on add and update resources. The ref cannot be + * inferred for remove resource and for operations on relationships. + * + * @param mapper the json api mapper + * @param operation the operation + * @return the ref + */ + private Ref inferRef(JsonApiMapper mapper, Operation operation) { + // Attempt to infer the ref from the data + if (operation.getData() != null && !operation.getData().isArray()) { + try { + Resource resource = mapper.forAtomicOperations() + .readResource(operation.getData()); + if (resource.getType() != null && isResourceOperation(resource)) { + if (OperationCode.ADD.equals(operation.getOperationCode())) { + return new Ref(resource.getType(), null, null, null); + } else if (OperationCode.UPDATE.equals(operation.getOperationCode())) { + if (resource.getLid() != null) { + return new Ref(resource.getType(), null, resource.getLid(), null); + } else if (resource.getId() != null) { + return new Ref(resource.getType(), resource.getId(), null, null); + } + } + } + } catch (JsonProcessingException e) { + // Do nothing as it will fall back on InvalidEntityBodyException + } + } + return null; + } + + /** + * Determines if the operation is on a resource. + *

+ * If there are attributes or relationships present then it is an operation on a + * resource and not a relationship. + * + * @param resource the resource + * @return true if it is a resource operation and not a relationship operation + */ + private boolean isResourceOperation(Resource resource) { + return (resource.getAttributes() != null && !resource.getAttributes().isEmpty()) + || (resource.getRelationships() != null && !resource.getRelationships().isEmpty()); + } + + /** + * Determines if the operation is on a resource. + *

+ * If the href contains /relationships/ then it is not an operation on a resource. + * + * @param href the path + * @return true if it is a resource operation and not a relationship operation + */ + private boolean isResourceOperation(String href) { + return !href.contains("/relationships/"); + } + + /** + * Add a document via atomic operations extension. + */ + private Supplier> handleAddOp( + String path, JsonNode dataValue, JsonApiAtomicOperationsRequestScope requestScope, OperationAction action) { + try { + JsonApiDocument value = requestScope.getMapper().forAtomicOperations().readData(dataValue); + Data data = value.getData(); + if (data == null || data.get() == null) { + throw new InvalidEntityBodyException("Expected an entity body but received none."); + } + + Collection resources = data.get(); + if (!path.contains("relationships")) { // Reserved key for relationships + String id = getSingleResource(resources).getId(); + + if (StringUtils.isEmpty(id)) { + throw new InvalidEntityBodyException( + "Atomic Operations extension requires all objects to have an assigned " + + "ID (temporary or permanent) when assigning relationships."); + } + String fullPath = path + "/" + id; + // Defer relationship updating until the end + getSingleResource(resources).setRelationships(null); + // Reparse since we mangle it first + action.doc = requestScope.getMapper().forAtomicOperations().readData(dataValue); + action.path = fullPath; + action.isPostProcessing = true; + } + PostVisitor visitor = new PostVisitor(new JsonApiAtomicOperationsRequestScope(path, value, requestScope)); + return visitor.visit(JsonApiParser.parse(path)); + } catch (HttpStatusException e) { + action.cause = e; + throw e; + } catch (IOException e) { + throw new InvalidEntityBodyException("Could not parse Atomic Operations extension value: " + dataValue); + } + } + + /** + * Update data via atomic operations extension. + */ + private Supplier> handleUpdateOp( + String path, JsonNode dataValue, JsonApiAtomicOperationsRequestScope requestScope, OperationAction action) { + try { + JsonApiDocument value = requestScope.getMapper().forAtomicOperations().readData(dataValue); + + if (!path.contains("relationships")) { // Reserved + Data data = value.getData(); + Collection resources = data.get(); + // Defer relationship updating until the end + getSingleResource(resources).setRelationships(null); + // Reparse since we mangle it first + action.doc = requestScope.getMapper().forAtomicOperations().readData(dataValue); + action.path = path; + action.isPostProcessing = true; + } + // Defer relationship updating until the end + PatchVisitor visitor = new PatchVisitor(new JsonApiAtomicOperationsRequestScope(path, value, requestScope)); + return visitor.visit(JsonApiParser.parse(path)); + } catch (IOException e) { + throw new InvalidEntityBodyException("Could not parse Atomic Operations extension value: " + dataValue); + } + } + + /** + * Remove data via atomic operations extension. + */ + private Supplier> handleRemoveOp(String path, + JsonNode dataValue, + JsonApiAtomicOperationsRequestScope requestScope) { + try { + JsonApiDocument value = requestScope.getMapper().forAtomicOperations().readData(dataValue); + String fullPath; + if (path.contains("relationships")) { // Reserved keyword for relationships + fullPath = path; + } else { + Data data = value.getData(); + if (data == null || data.get() == null) { + fullPath = path; + } else { + Collection resources = data.get(); + String id = getSingleResource(resources).getId(); + fullPath = path + "/" + id; + } + } + DeleteVisitor visitor = new DeleteVisitor( + new JsonApiAtomicOperationsRequestScope(path, value, requestScope)); + return visitor.visit(JsonApiParser.parse(fullPath)); + } catch (IOException e) { + throw new InvalidEntityBodyException("Could not parse Atomic Operations extension value: " + dataValue); + } + } + + /** + * Post-process relationships after all objects for request have been created. + * + * This is required since we have no way of determining which object should be created first. That is, + * in the case of a cyclic relationship between 2 or more newly created objects, some object needs to be created + * first. In our case, we will create all objects and then add the relationships in memory. Finally, at the end, we + * rely on the commit of DataStoreTransaction to handle the creation properly. + * + * @param requestScope request scope + */ + private void postProcessRelationships(JsonApiAtomicOperationsRequestScope requestScope) { + actions.forEach(action -> action.postProcess(requestScope)); + } + + /** + * Turn an exception into a proper error response from Atomic Operations extension. + */ + private void throwErrorResponse() { + ArrayNode errorContainer = getErrorContainer(); + + boolean failed = false; + for (OperationAction action : actions) { + failed = processAction(errorContainer, failed, action); + } + + JsonApiAtomicOperationsException failure = + new JsonApiAtomicOperationsException(HttpStatus.SC_BAD_REQUEST, errorContainer); + + // attach error causes to exception + for (OperationAction action : actions) { + if (action.cause != null) { + failure.addSuppressed(action.cause); + } + } + + throw failure; + } + + private ArrayNode getErrorContainer() { + return JsonNodeFactory.instance.arrayNode(); + } + + private boolean processAction(ArrayNode errorList, boolean failed, OperationAction action) { + ObjectNode container = JsonNodeFactory.instance.objectNode(); + ArrayNode errors = JsonNodeFactory.instance.arrayNode(); + container.set("errors", errors); + errorList.add(container); + + if (action.cause != null) { + // this is the failed operation + errors.add(toErrorNode(action.cause.getMessage(), action.cause.getStatus())); + failed = true; + } else if (!failed) { + // this operation succeeded + errors.add(ERR_NODE_ERR_IN_SUBSEQUENT_OPERATION); + } else { + // this operation never ran + errors.add(ERR_NODE_OPERATION_NOT_RUN); + } + return failed; + } + + protected String getRootUri() { + return this.rootUri; + } + + /** + * Clear all relationships for all resources in document. + */ + private static void clearAllExceptRelationships(JsonApiDocument doc) { + Data data = doc.getData(); + if (data == null || data.get() == null) { + return; + } + data.get().forEach(JsonApiAtomicOperations::clearAllExceptRelationships); + } + + /** + * Clear all properties except the relationships. + */ + private static void clearAllExceptRelationships(Resource resource) { + resource.setAttributes(null); + resource.setLinks(null); + resource.setMeta(null); + } + + /** + * Convert a message and status to an error node. + * + */ + private static JsonNode toErrorNode(String detail, Integer status) { + ObjectNode formattedError = JsonNodeFactory.instance.objectNode(); + formattedError.set("detail", JsonNodeFactory.instance.textNode(Encode.forHtml(detail))); + if (status != null) { + formattedError.set("status", JsonNodeFactory.instance.textNode(status.toString())); + } + return formattedError; + } + + /** + * Merge response documents to create final response. + */ + private static JsonNode mergeResponse( + List>> results, + JsonApiMapper mapper + ) { + List list = new ArrayList<>(); + for (Supplier> result : results) { + JsonApiDocument document = result.get().getRight(); + if (document != null) { + document.getData().get().stream().map(resource -> new Result(resource, document.getMeta())) + .forEach(list::add); + } else { + list.add(new Result(null)); + } + } + return mapper.getObjectMapper().valueToTree(new Results(list)); + } + + /** + * Determine whether or not ext = "https://jsonapi.org/ext/atomic" is present in header. + * + * @param header the header + * @return true if it is Atomic Operations + */ + public static boolean isAtomicOperationsExtension(String header) { + if (header == null) { + return false; + } + + // Find ext="https://jsonapi.org/ext/atomic" + return Arrays.stream(header.split(";")) + .map(key -> key.split("=")) + .filter(value -> value.length == 2) + .anyMatch(value -> value[0].trim().equals("ext") + && parameterValues(value[1]).contains(EXTENSION)); + } + + private static Set parameterValues(String value) { + String trimmed = value.trim(); + if (trimmed.length() > 1 && trimmed.startsWith("\"") && trimmed.endsWith("\"")) { + String unquoted = trimmed.substring(1, trimmed.length() - 1); + Set result = new HashSet<>(); + Collections.addAll(result, unquoted.split(" ")); + return result; + } + return Collections.singleton(trimmed); + } + + private static Resource getSingleResource(Collection resources) { + if (resources == null || resources.size() != 1) { + throw new InvalidEntityBodyException("Expected single resource."); + } + return IterableUtils.first(resources); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiAtomicOperationsMapper.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiAtomicOperationsMapper.java new file mode 100644 index 0000000000..f993701dec --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiAtomicOperationsMapper.java @@ -0,0 +1,54 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.jsonapi.extensions; + +import com.yahoo.elide.jsonapi.models.Data; +import com.yahoo.elide.jsonapi.models.JsonApiDocument; +import com.yahoo.elide.jsonapi.models.Operations; +import com.yahoo.elide.jsonapi.models.Resource; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.ArrayList; +import java.util.List; + +/** + * The mapper for the JSON API Atomic Operations extension. + */ +public class JsonApiAtomicOperationsMapper { + protected final ObjectMapper objectMapper; + + public JsonApiAtomicOperationsMapper(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public Operations readDoc(String operationsDoc) throws JsonProcessingException { + return this.objectMapper.readValue(operationsDoc, Operations.class); + } + + public Resource readResource(JsonNode resource) throws JsonProcessingException { + return objectMapper.treeToValue(resource, Resource.class); + } + + public JsonApiDocument readData(JsonNode data) throws JsonProcessingException { + JsonApiDocument value = new JsonApiDocument(); + if (data != null) { + if (data.isArray()) { + List dataResources = new ArrayList<>(); + for (JsonNode item : data) { + dataResources.add(readResource(item)); + value.setData(new Data<>(dataResources)); + } + } else { + Resource resource = readResource(data); + value.setData(new Data<>(resource)); + } + } + return value; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiAtomicOperationsRequestScope.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiAtomicOperationsRequestScope.java new file mode 100644 index 0000000000..26d6690955 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiAtomicOperationsRequestScope.java @@ -0,0 +1,75 @@ +/* + * Copyright 2017, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.jsonapi.extensions; + +import com.yahoo.elide.ElideSettings; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.security.User; +import com.yahoo.elide.jsonapi.EntityProjectionMaker; +import com.yahoo.elide.jsonapi.models.JsonApiDocument; + +import jakarta.ws.rs.core.MultivaluedMap; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * The request scope for the JSON API Atomic Operations extension. + */ +public class JsonApiAtomicOperationsRequestScope extends RequestScope { + + /** + * Outer RequestScope constructor for use by Atomic Extension. + * + * @param baseUrlEndPoint base URL with prefix endpoint + * @param path the URL path + * @param apiVersion client requested API version + * @param transaction current database transaction + * @param user request user + * @param requestId request ID + * @param queryParams request query parameters + * @param requestHeaders request headers + * @param elideSettings Elide settings object + */ + public JsonApiAtomicOperationsRequestScope( + String baseUrlEndPoint, + String path, + String apiVersion, + DataStoreTransaction transaction, + User user, + UUID requestId, + MultivaluedMap queryParams, + Map> requestHeaders, + ElideSettings elideSettings) { + super( + baseUrlEndPoint, + path, + apiVersion, + (JsonApiDocument) null, + transaction, + user, + queryParams, + requestHeaders, + requestId, + elideSettings + ); + } + + /** + * Inner RequestScope copy constructor for use by Atomic Extension actions. + * + * @param path the URL path + * @param jsonApiDocument document + * @param scope outer request scope + */ + public JsonApiAtomicOperationsRequestScope(String path, JsonApiDocument jsonApiDocument, + JsonApiAtomicOperationsRequestScope scope) { + super(path, scope.getApiVersion(), jsonApiDocument, scope); + this.setEntityProjection(new EntityProjectionMaker(dictionary, this).parsePath(path)); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiPatch.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiJsonPatch.java similarity index 85% rename from elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiPatch.java rename to elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiJsonPatch.java index 7c5c43b5a6..31c75a34d1 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiPatch.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiJsonPatch.java @@ -33,15 +33,20 @@ import java.io.IOException; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.function.Supplier; import java.util.stream.Collectors; /** - * Json API patch extension. - * See: http://jsonapi.org/extensions/jsonpatch/ + * JSON API JSON patch extension. + * @see JSON Patch Extension */ -public class JsonApiPatch { +public class JsonApiJsonPatch { + public static final String EXTENSION = "jsonpatch"; + private static class PatchAction { public final Patch patch; @@ -58,12 +63,12 @@ public PatchAction(Patch patch) { this.cause = null; } - public void postProcess(PatchRequestScope requestScope) { + public void postProcess(JsonApiJsonPatchRequestScope requestScope) { if (isPostProcessing) { try { // Only update relationships clearAllExceptRelationships(doc); - PatchVisitor visitor = new PatchVisitor(new PatchRequestScope(path, doc, requestScope)); + PatchVisitor visitor = new PatchVisitor(new JsonApiJsonPatchRequestScope(path, doc, requestScope)); visitor.visit(JsonApiParser.parse(path)); } catch (HttpStatusException e) { cause = e; @@ -101,14 +106,14 @@ public void postProcess(PatchRequestScope requestScope) { public static Supplier> processJsonPatch(DataStore dataStore, String uri, String patchDoc, - PatchRequestScope requestScope) { + JsonApiJsonPatchRequestScope requestScope) { List actions; try { - actions = requestScope.getMapper().readJsonApiPatchExtDoc(patchDoc); + actions = requestScope.getMapper().forJsonPatch().readDoc(patchDoc); } catch (IOException e) { throw new InvalidEntityBodyException(patchDoc); } - JsonApiPatch processor = new JsonApiPatch(dataStore, actions, uri, requestScope); + JsonApiJsonPatch processor = new JsonApiJsonPatch(dataStore, actions, uri, requestScope); return processor.processActions(requestScope); } @@ -119,7 +124,7 @@ public static Supplier> processJsonPatch(DataStore dataS * @param actions List of patch actions * @param rootUri root URI */ - private JsonApiPatch(DataStore dataStore, + private JsonApiJsonPatch(DataStore dataStore, List actions, String rootUri, RequestScope requestScope) { @@ -132,7 +137,7 @@ private JsonApiPatch(DataStore dataStore, * * @return Pair (return code, JsonNode) */ - private Supplier> processActions(PatchRequestScope requestScope) { + private Supplier> processActions(JsonApiJsonPatchRequestScope requestScope) { try { List>> results = handleActions(requestScope); @@ -160,7 +165,7 @@ private Supplier> processActions(PatchRequestScope reque * @param requestScope outer request scope * @return List of responders */ - private List>> handleActions(PatchRequestScope requestScope) { + private List>> handleActions(JsonApiJsonPatchRequestScope requestScope) { return actions.stream().map(action -> { Supplier> result; try { @@ -202,9 +207,9 @@ private List>> handleActions(PatchReques * Add a document via patch extension. */ private Supplier> handleAddOp( - String path, JsonNode patchValue, PatchRequestScope requestScope, PatchAction action) { + String path, JsonNode patchValue, JsonApiJsonPatchRequestScope requestScope, PatchAction action) { try { - JsonApiDocument value = requestScope.getMapper().readJsonApiPatchExtValue(patchValue); + JsonApiDocument value = requestScope.getMapper().forJsonPatch().readValue(patchValue); Data data = value.getData(); if (data == null || data.get() == null) { throw new InvalidEntityBodyException("Expected an entity body but received none."); @@ -222,11 +227,11 @@ private Supplier> handleAddOp( // Defer relationship updating until the end getSingleResource(resources).setRelationships(null); // Reparse since we mangle it first - action.doc = requestScope.getMapper().readJsonApiPatchExtValue(patchValue); + action.doc = requestScope.getMapper().forJsonPatch().readValue(patchValue); action.path = fullPath; action.isPostProcessing = true; } - PostVisitor visitor = new PostVisitor(new PatchRequestScope(path, value, requestScope)); + PostVisitor visitor = new PostVisitor(new JsonApiJsonPatchRequestScope(path, value, requestScope)); return visitor.visit(JsonApiParser.parse(path)); } catch (HttpStatusException e) { action.cause = e; @@ -240,9 +245,9 @@ private Supplier> handleAddOp( * Replace data via patch extension. */ private Supplier> handleReplaceOp( - String path, JsonNode patchVal, PatchRequestScope requestScope, PatchAction action) { + String path, JsonNode patchVal, JsonApiJsonPatchRequestScope requestScope, PatchAction action) { try { - JsonApiDocument value = requestScope.getMapper().readJsonApiPatchExtValue(patchVal); + JsonApiDocument value = requestScope.getMapper().forJsonPatch().readValue(patchVal); if (!path.contains("relationships")) { // Reserved Data data = value.getData(); @@ -250,12 +255,12 @@ private Supplier> handleReplaceOp( // Defer relationship updating until the end getSingleResource(resources).setRelationships(null); // Reparse since we mangle it first - action.doc = requestScope.getMapper().readJsonApiPatchExtValue(patchVal); + action.doc = requestScope.getMapper().forJsonPatch().readValue(patchVal); action.path = path; action.isPostProcessing = true; } // Defer relationship updating until the end - PatchVisitor visitor = new PatchVisitor(new PatchRequestScope(path, value, requestScope)); + PatchVisitor visitor = new PatchVisitor(new JsonApiJsonPatchRequestScope(path, value, requestScope)); return visitor.visit(JsonApiParser.parse(path)); } catch (IOException e) { throw new InvalidEntityBodyException("Could not parse patch extension value: " + patchVal); @@ -267,9 +272,9 @@ private Supplier> handleReplaceOp( */ private Supplier> handleRemoveOp(String path, JsonNode patchValue, - PatchRequestScope requestScope) { + JsonApiJsonPatchRequestScope requestScope) { try { - JsonApiDocument value = requestScope.getMapper().readJsonApiPatchExtValue(patchValue); + JsonApiDocument value = requestScope.getMapper().forJsonPatch().readValue(patchValue); String fullPath; if (path.contains("relationships")) { // Reserved keyword for relationships fullPath = path; @@ -284,7 +289,7 @@ private Supplier> handleRemoveOp(String path, } } DeleteVisitor visitor = new DeleteVisitor( - new PatchRequestScope(path, value, requestScope)); + new JsonApiJsonPatchRequestScope(path, value, requestScope)); return visitor.visit(JsonApiParser.parse(fullPath)); } catch (IOException e) { throw new InvalidEntityBodyException("Could not parse patch extension value: " + patchValue); @@ -301,7 +306,7 @@ private Supplier> handleRemoveOp(String path, * * @param requestScope request scope */ - private void postProcessRelationships(PatchRequestScope requestScope) { + private void postProcessRelationships(JsonApiJsonPatchRequestScope requestScope) { actions.forEach(action -> action.postProcess(requestScope)); } @@ -361,7 +366,7 @@ private static void clearAllExceptRelationships(JsonApiDocument doc) { if (data == null || data.get() == null) { return; } - data.get().forEach(JsonApiPatch::clearAllExceptRelationships); + data.get().forEach(JsonApiJsonPatch::clearAllExceptRelationships); } /** @@ -423,7 +428,18 @@ public static boolean isPatchExtension(String header) { return Arrays.stream(header.split(";")) .map(key -> key.split("=")) .filter(value -> value.length == 2) - .anyMatch(value -> value[0].trim().equals("ext") && value[1].trim().equals("jsonpatch")); + .anyMatch(value -> value[0].trim().equals("ext") && parameterValues(value[1]).contains(EXTENSION)); + } + + private static Set parameterValues(String value) { + String trimmed = value.trim(); + if (trimmed.length() > 1 && trimmed.startsWith("\"") && trimmed.endsWith("\"")) { + String unquoted = trimmed.substring(1, trimmed.length() - 1); + Set result = new HashSet<>(); + Collections.addAll(result, unquoted.split(" ")); + return result; + } + return Collections.singleton(trimmed); } private static Resource getSingleResource(Collection resources) { diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiJsonPatchMapper.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiJsonPatchMapper.java new file mode 100644 index 0000000000..6d299f1058 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiJsonPatchMapper.java @@ -0,0 +1,52 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.jsonapi.extensions; + +import com.yahoo.elide.jsonapi.models.JsonApiDocument; +import com.yahoo.elide.jsonapi.models.Patch; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; + +import java.io.IOException; +import java.util.List; + +/** + * The mapper for the JSON API JSON Patch extension. + */ +public class JsonApiJsonPatchMapper { + protected final ObjectMapper objectMapper; + + public JsonApiJsonPatchMapper(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * Read json api patch ext value. + * + * @param value the value + * @return the json api document + * @throws JsonProcessingException the json processing exception + */ + public JsonApiDocument readValue(JsonNode value) throws JsonProcessingException { + JsonNode data = JsonNodeFactory.instance.objectNode().set("data", value); + return this.objectMapper.treeToValue(data, JsonApiDocument.class); + } + + /** + * Read json api patch ext doc. + * + * @param doc the doc + * @return the list + * @throws IOException the iO exception + */ + public List readDoc(String doc) throws IOException { + return this.objectMapper.readValue(doc, + this.objectMapper.getTypeFactory().constructCollectionType(List.class, Patch.class)); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/PatchRequestScope.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiJsonPatchRequestScope.java similarity index 87% rename from elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/PatchRequestScope.java rename to elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiJsonPatchRequestScope.java index 23f595b78a..fd1e618d5a 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/PatchRequestScope.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiJsonPatchRequestScope.java @@ -19,9 +19,9 @@ import java.util.UUID; /** - * Special request scope for Patch Extension. + * The request scope for the JSON API JSON Patch extension. */ -public class PatchRequestScope extends RequestScope { +public class JsonApiJsonPatchRequestScope extends RequestScope { /** * Outer RequestScope constructor for use by Patch Extension. @@ -36,7 +36,7 @@ public class PatchRequestScope extends RequestScope { * @param requestHeaders request headers * @param elideSettings Elide settings object */ - public PatchRequestScope( + public JsonApiJsonPatchRequestScope( String baseUrlEndPoint, String path, String apiVersion, @@ -67,7 +67,8 @@ public PatchRequestScope( * @param jsonApiDocument document * @param scope outer request scope */ - public PatchRequestScope(String path, JsonApiDocument jsonApiDocument, PatchRequestScope scope) { + public JsonApiJsonPatchRequestScope(String path, JsonApiDocument jsonApiDocument, + JsonApiJsonPatchRequestScope scope) { super(path, scope.getApiVersion(), jsonApiDocument, scope); this.setEntityProjection(new EntityProjectionMaker(dictionary, this).parsePath(path)); } diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Operation.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Operation.java new file mode 100644 index 0000000000..9776f696be --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Operation.java @@ -0,0 +1,141 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.jsonapi.models; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.annotation.JsonValue; +import com.fasterxml.jackson.databind.JsonNode; + +/** + * The JSON Atomic Operation extension entity body. + */ +@JsonPropertyOrder({"op", "ref", "href", "data", "meta"}) +@JsonInclude(Include.NON_NULL) +public class Operation { + + /** + * Operation Code. + */ + public enum OperationCode { + /** + * The ADD. + */ + ADD(1, "add"), + /** + * The REMOVE. + */ + REMOVE(2, "remove"), + /** + * The UPDATE. + */ + UPDATE(3, "update"); + + private final int id; + private final String name; + + OperationCode(int id, String name) { + this.id = id; + this.name = name; + } + + /** + * Gets name. + * + * @return the name + */ + @JsonValue + public String getName() { + return this.name; + } + + public int getId() { + return this.id; + } + } + + private final OperationCode operationCode; + private final Ref ref; + private final String href; + private final JsonNode data; + private final Meta meta; + + /** + * Gets the operation code. + * + * @return operation of the patch + */ + @JsonProperty("op") + public OperationCode getOperationCode() { + return this.operationCode; + } + + /** + * Gets the reference that identifies the target of the operation. + * + * @return The reference that identifies the target of the operation. + */ + public Ref getRef() { + return this.ref; + } + + /** + * Gets the URI-reference that identifies the target of the operation. + * + * @return The URI-reference that identifies the target of the operation. + */ + public String getHref() { + return this.href; + } + + /** + * Gets the data. + * + * @return The operation's primary data. This is either a single resource or + * array of resources. + */ + public JsonNode getData() { + return this.data; + } + + /** + * Gets the metadata. + * + * @return The metadata of the operation which contains non-standard + * meta-information about the operation. + */ + public Meta getMeta() { + return this.meta; + } + + /** + * Creates a new Atomic Operation entity body. + * + * @param operationCode The Operation Code which is either add, update or + * remove. + * @param ref The reference that identifies the target of the operation. + * @param href The URI-reference that identifies the target of the operation. + * @param data The operation's primary data. This is either a single + * resource or array of resources. + * @param meta The metadata of the operation which contains + * non-standard meta-information about the operation. + */ + @JsonCreator + public Operation(@JsonProperty("op") OperationCode operationCode, + @JsonProperty("ref") Ref ref, + @JsonProperty("href") String href, + @JsonProperty("data") JsonNode data, + @JsonProperty("meta") Meta meta) { + this.operationCode = operationCode; + this.ref = ref; + this.href = href; + this.data = data; + this.meta = meta; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Operations.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Operations.java new file mode 100644 index 0000000000..920a5b291e --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Operations.java @@ -0,0 +1,32 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.jsonapi.models; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * The JSON Atomic Operations extension entity body. + */ +@JsonInclude(Include.NON_NULL) +public class Operations { + + @JsonProperty("atomic:operations") + private final List operations; + + public List getOperations() { + return this.operations; + } + + @JsonCreator + public Operations(@JsonProperty("atomic:operations") List operations) { + this.operations = operations; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Ref.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Ref.java new file mode 100644 index 0000000000..b869888914 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Ref.java @@ -0,0 +1,69 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.jsonapi.models; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +import lombok.ToString; + +/** + * The reference that identifies the target of the operation. + */ +@ToString +@JsonPropertyOrder({"type", "id", "lid", "relationship"}) +@JsonInclude(Include.NON_NULL) +public class Ref { + + private String type; + private String id; + private String lid; + private String relationship; + + public Ref(@JsonProperty("type") String type, + @JsonProperty("id") String id, + @JsonProperty("lid") String lid, + @JsonProperty("relationship") String relationship) { + this.type = type; + this.id = id; + this.lid = lid; + this.relationship = relationship; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getLid() { + return lid; + } + + public void setLid(String lid) { + this.lid = lid; + } + + public String getRelationship() { + return relationship; + } + + public void setRelationship(String relationship) { + this.relationship = relationship; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Resource.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Resource.java index c8352e6805..638d6c8cd5 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Resource.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Resource.java @@ -39,6 +39,7 @@ public class Resource { @JsonProperty(required = true) private String type; private String id; + private String lid; private Map attributes; private Map relationships; private Map links; @@ -54,22 +55,33 @@ public Resource(String type, String id) { public Resource(@JsonProperty("type") String type, @JsonProperty("id") String id, + @JsonProperty("lid") String lid, @JsonProperty("attributes") Map attributes, @JsonProperty("relationships") Map relationships, @JsonProperty("links") Map links, @JsonProperty("meta") Meta meta) { this.type = type; this.id = id; + this.lid = lid; this.attributes = attributes; this.relationships = relationships; this.links = links; this.meta = meta; + if (this.id == null) { + this.id = lid; + } } + @JsonInclude(JsonInclude.Include.NON_NULL) public String getId() { return id; } + @JsonInclude(JsonInclude.Include.NON_NULL) + public String getLid() { + return lid; + } + public void setRelationships(Map relationships) { this.relationships = relationships; } @@ -89,6 +101,10 @@ public void setId(String id) { this.id = id; } + public void setLid(String lid) { + this.lid = lid; + } + public void setAttributes(Map obj) { this.attributes = obj; } @@ -125,13 +141,13 @@ public void setLinks(Map links) { * @return linkage */ public ResourceIdentifier toResourceIdentifier() { - return new ResourceIdentifier(type, id); + return new ResourceIdentifier(type, id != null ? id : lid); } @Override public int hashCode() { // We hope that type and id are effectively final after jackson constructs the object... - return new HashCodeBuilder(37, 17).append(type).append(id).build(); + return new HashCodeBuilder(37, 17).append(type).append(id != null ? id : lid).build(); } @Override @@ -142,6 +158,7 @@ public boolean equals(Object obj) { if (obj instanceof Resource) { Resource that = (Resource) obj; return Objects.equals(this.id, that.id) + && Objects.equals(this.lid, that.lid) && Objects.equals(this.attributes, that.attributes) && Objects.equals(this.type, that.type) && Objects.equals(this.relationships, that.relationships); @@ -158,14 +175,15 @@ public PersistentResource toPersistentResource(RequestScope requestScope) if (cls == null) { throw new UnknownEntityException(type); } - if (id == null) { - throw new InvalidObjectIdentifierException(id, type); + String identifier = id != null ? id : lid; + if (identifier == null) { + throw new InvalidObjectIdentifierException(identifier, type); } EntityProjection projection = EntityProjection.builder() .type(cls) .build(); - return PersistentResource.loadRecord(projection, id, requestScope); + return PersistentResource.loadRecord(projection, identifier, requestScope); } } diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Result.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Result.java new file mode 100644 index 0000000000..f99d28a114 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Result.java @@ -0,0 +1,38 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.jsonapi.models; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * The JSON Atomic Operations extension result body. + */ +public class Result { + private final Resource data; + private final Meta meta; + + public Resource getData() { + return this.data; + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public Meta getMeta() { + return this.meta; + } + + @JsonCreator + public Result(@JsonProperty("data") Resource data, @JsonProperty("meta") Meta meta) { + this.data = data; + this.meta = meta; + } + + public Result(Resource data) { + this.data = data; + this.meta = null; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Results.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Results.java new file mode 100644 index 0000000000..7b42271f40 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Results.java @@ -0,0 +1,32 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.jsonapi.models; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * The JSON Atomic Operations extension result body. + */ +@JsonInclude(Include.NON_NULL) +public class Results { + + @JsonProperty("atomic:results") + private final List results; + + public List getResults() { + return this.results; + } + + @JsonCreator + public Results(@JsonProperty("atomic:results") List results) { + this.results = results; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/resources/JsonApiEndpoint.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/resources/JsonApiEndpoint.java index b0007242c1..0c067cd71a 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/resources/JsonApiEndpoint.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/resources/JsonApiEndpoint.java @@ -11,6 +11,7 @@ import com.yahoo.elide.ElideResponse; import com.yahoo.elide.annotation.PATCH; import com.yahoo.elide.core.security.User; +import com.yahoo.elide.jsonapi.JsonApi; import com.yahoo.elide.utils.HeaderUtils; import com.yahoo.elide.utils.ResourceUtils; import org.apache.commons.lang3.StringUtils; @@ -66,7 +67,7 @@ public JsonApiEndpoint( */ @POST @Path("{path:.*}") - @Consumes(JSONAPI_CONTENT_TYPE) + @Consumes(JsonApi.MEDIA_TYPE) public Response post( @PathParam("path") String path, @Context UriInfo uriInfo, @@ -120,7 +121,7 @@ public Response get( */ @PATCH @Path("{path:.*}") - @Consumes(JSONAPI_CONTENT_TYPE) + @Consumes(JsonApi.MEDIA_TYPE) public Response patch( @HeaderParam("Content-Type") String contentType, @HeaderParam("accept") String accept, @@ -149,7 +150,7 @@ public Response patch( */ @DELETE @Path("{path:.*}") - @Consumes(JSONAPI_CONTENT_TYPE) + @Consumes(JsonApi.MEDIA_TYPE) public Response delete( @PathParam("path") String path, @Context UriInfo uriInfo, @@ -165,6 +166,35 @@ public Response delete( user, apiVersion, UUID.randomUUID())); } + /** + * Operations handler. + * + * @param path request path + * @param uriInfo URI info + * @param headers the request headers + * @param securityContext security context + * @param jsonapiDocument post data as jsonapi document + * @return response + */ + @POST + @Path("/operations") + @Consumes(JsonApi.AtomicOperations.MEDIA_TYPE) + public Response operations( + @HeaderParam("Content-Type") String contentType, + @HeaderParam("accept") String accept, + @PathParam("path") String path, + @Context UriInfo uriInfo, + @Context HttpHeaders headers, + @Context SecurityContext securityContext, + String jsonapiDocument) { + MultivaluedMap queryParams = uriInfo.getQueryParameters(); + String apiVersion = HeaderUtils.resolveApiVersion(headers.getRequestHeaders()); + Map> requestHeaders = headerProcessor.process(headers.getRequestHeaders()); + User user = new SecurityContextUser(securityContext); + return build(elide.operations(getBaseUrlEndpoint(uriInfo), contentType, accept, path, jsonapiDocument, + queryParams, requestHeaders, user, apiVersion, UUID.randomUUID())); + } + private static Response build(ElideResponse response) { return Response.status(response.getResponseCode()).entity(response.getBody()).build(); } diff --git a/elide-core/src/main/resources/META-INF/native-image/com.yahoo.elide/elide-core/reflect-config.json b/elide-core/src/main/resources/META-INF/native-image/com.yahoo.elide/elide-core/reflect-config.json index 42f3ebf66e..3066c2f689 100644 --- a/elide-core/src/main/resources/META-INF/native-image/com.yahoo.elide/elide-core/reflect-config.json +++ b/elide-core/src/main/resources/META-INF/native-image/com.yahoo.elide/elide-core/reflect-config.json @@ -183,12 +183,54 @@ "allDeclaredClasses" : true, "allPublicClasses" : true }, + { + "name": "com.yahoo.elide.jsonapi.models.Data", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, { "name": "com.yahoo.elide.jsonapi.models.JsonApiDocument", "allDeclaredFields": true, "allDeclaredMethods": true, "allDeclaredConstructors": true }, + { + "name": "com.yahoo.elide.jsonapi.models.KeyValMap", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "name": "com.yahoo.elide.jsonapi.models.Meta", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "name": "com.yahoo.elide.jsonapi.models.Operation", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "name": "com.yahoo.elide.jsonapi.models.Operations", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "name": "com.yahoo.elide.jsonapi.models.Patch", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "name": "com.yahoo.elide.jsonapi.models.Ref", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, { "name": "com.yahoo.elide.jsonapi.models.Relationship", "allDeclaredFields": true, @@ -208,7 +250,13 @@ "allDeclaredConstructors": true }, { - "name": "com.yahoo.elide.jsonapi.models.Meta", + "name": "com.yahoo.elide.jsonapi.models.Result", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "name": "com.yahoo.elide.jsonapi.models.Results", "allDeclaredFields": true, "allDeclaredMethods": true, "allDeclaredConstructors": true diff --git a/elide-core/src/test/java/com/yahoo/elide/core/PersistentResourceTest.java b/elide-core/src/test/java/com/yahoo/elide/core/PersistentResourceTest.java index 6b5b0ca2b5..59db2fba61 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/PersistentResourceTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/PersistentResourceTest.java @@ -44,7 +44,7 @@ import com.yahoo.elide.core.security.TestUser; import com.yahoo.elide.core.security.User; import com.yahoo.elide.core.type.ClassType; -import com.yahoo.elide.jsonapi.extensions.PatchRequestScope; +import com.yahoo.elide.jsonapi.extensions.JsonApiJsonPatchRequestScope; import com.yahoo.elide.jsonapi.models.Data; import com.yahoo.elide.jsonapi.models.Relationship; import com.yahoo.elide.jsonapi.models.Resource; @@ -645,7 +645,7 @@ public void testSuccessfulOneToOneRelationshipAddNull() throws Exception { PersistentResource leftResource = new PersistentResource<>(left, "2", goodScope); - Relationship ids = new Relationship(null, new Data<>(new Resource("right", null, null, null, null, null))); + Relationship ids = new Relationship(null, new Data<>(new Resource("right", null, null, null, null, null, null))); InvalidObjectIdentifierException thrown = assertThrows( InvalidObjectIdentifierException.class, @@ -2850,7 +2850,7 @@ public void testRelationChangeSpecType() { @Test public void testPatchRequestScope() { DataStoreTransaction tx = mock(DataStoreTransaction.class); - PatchRequestScope parentScope = new PatchRequestScope( + JsonApiJsonPatchRequestScope parentScope = new JsonApiJsonPatchRequestScope( null, "/book", NO_VERSION, @@ -2860,7 +2860,7 @@ public void testPatchRequestScope() { null, Collections.emptyMap(), elideSettings); - PatchRequestScope scope = new PatchRequestScope( + JsonApiJsonPatchRequestScope scope = new JsonApiJsonPatchRequestScope( parentScope.getPath(), parentScope.getJsonApiDocument(), parentScope); // verify wrap works assertEquals(parentScope.getUpdateStatusCode(), scope.getUpdateStatusCode()); diff --git a/elide-core/src/test/java/com/yahoo/elide/core/datastore/inmemory/InMemoryStoreTransactionTest.java b/elide-core/src/test/java/com/yahoo/elide/core/datastore/inmemory/InMemoryStoreTransactionTest.java index 764aa1a908..10ada3e463 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/datastore/inmemory/InMemoryStoreTransactionTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/datastore/inmemory/InMemoryStoreTransactionTest.java @@ -600,6 +600,7 @@ public void testInMemoryDataStore() { "example.NoShareEntity", "example.NoUpdateEntity", "example.Parent", + "example.Person", "example.Post", "example.PrimitiveId", "example.Publisher", diff --git a/elide-core/src/test/java/com/yahoo/elide/core/exceptions/ErrorMapperTest.java b/elide-core/src/test/java/com/yahoo/elide/core/exceptions/ErrorMapperTest.java index 3f75f3569d..eb5190057f 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/exceptions/ErrorMapperTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/exceptions/ErrorMapperTest.java @@ -80,7 +80,7 @@ public void testElideRuntimeExceptionNoErrorMapper() throws Exception { doThrow(EXPECTED_EXCEPTION).when(tx).preCommit(any()); RuntimeException result = assertThrows(RuntimeException.class, () -> elide.post(baseUrl, "/testModel", body, null, NO_VERSION)); - assertEquals(EXPECTED_EXCEPTION, result.getCause()); + assertEquals(EXPECTED_EXCEPTION, result); verify(tx).close(); } @@ -123,7 +123,7 @@ public void testElideRuntimeExceptionWithErrorMapperUnmapped() throws Exception doThrow(EXPECTED_EXCEPTION).when(tx).preCommit(any()); RuntimeException result = assertThrows(RuntimeException.class, () -> elide.post(baseUrl, "/testModel", body, null, NO_VERSION)); - assertEquals(EXPECTED_EXCEPTION, result.getCause()); + assertEquals(EXPECTED_EXCEPTION, result); verify(tx).close(); } diff --git a/elide-core/src/test/java/com/yahoo/elide/core/lifecycle/LifeCycleTest.java b/elide-core/src/test/java/com/yahoo/elide/core/lifecycle/LifeCycleTest.java index 62016ba427..38c47a7eb6 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/lifecycle/LifeCycleTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/lifecycle/LifeCycleTest.java @@ -53,6 +53,7 @@ import com.yahoo.elide.core.security.TestUser; import com.yahoo.elide.core.security.User; import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.jsonapi.JsonApi; import com.google.common.collect.ImmutableSet; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -701,205 +702,624 @@ public void testLegacyElidePatchExtensionCreate() throws Exception { verify(tx).close(); } - @Test - public void failElidePatchExtensionCreate() throws Exception { - DataStore store = mock(DataStore.class); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - FieldTestModel mockModel = mock(FieldTestModel.class); - - Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); - - String body = "[{\"op\": \"add\",\"path\": \"/testModel\",\"value\":{" - + "\"type\":\"testModel\",\"attributes\": {\"field\":\"Foo\"}}}]"; - - RequestScope scope = buildRequestScope(dictionary, tx); - when(store.beginTransaction()).thenReturn(tx); - when(tx.createNewObject(ClassType.of(FieldTestModel.class), scope)).thenReturn(mockModel); - - String contentType = JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; - ElideResponse response = - elide.patch(baseUrl, contentType, contentType, "/", body, null, NO_VERSION); - assertEquals(HttpStatus.SC_BAD_REQUEST, response.getResponseCode()); - assertEquals( - "[{\"errors\":[{\"detail\":\"Bad Request Body'Patch extension requires all objects to have an assigned ID (temporary or permanent) when assigning relationships.'\",\"status\":\"400\"}]}]", - response.getBody()); - - verify(mockModel, never()).classCallback(eq(CREATE), eq(PRESECURITY)); - verify(mockModel, never()).classCallback(eq(CREATE), eq(PREFLUSH)); - verify(mockModel, never()).classCallback(eq(CREATE), eq(PRECOMMIT)); - verify(mockModel, never()).classCallback(eq(CREATE), eq(POSTCOMMIT)); - verify(mockModel, never()).classCallback(eq(UPDATE), any()); - verify(mockModel, never()).classCallback(eq(DELETE), any()); - - verify(mockModel, never()).classAllFieldsCallback(any(), any()); - verify(mockModel, never()).classAllFieldsCallback(eq(CREATE), eq(PRECOMMIT)); - - verify(mockModel, never()).attributeCallback(eq(CREATE), eq(PRESECURITY), any()); - verify(mockModel, never()).attributeCallback(eq(CREATE), eq(PREFLUSH), any()); - verify(mockModel, never()).attributeCallback(eq(CREATE), eq(PRECOMMIT), any()); - verify(mockModel, never()).attributeCallback(eq(CREATE), eq(POSTCOMMIT), any()); - verify(mockModel, never()).attributeCallback(eq(UPDATE), any(), any()); - verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); - - verify(mockModel, never()).relationCallback(eq(CREATE), eq(PRESECURITY), any()); - verify(mockModel, never()).relationCallback(eq(CREATE), eq(PREFLUSH), any()); - verify(mockModel, never()).relationCallback(eq(CREATE), eq(PRECOMMIT), any()); - verify(mockModel, never()).relationCallback(eq(CREATE), eq(POSTCOMMIT), any()); - verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); - verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); - - verify(tx, never()).preCommit(any()); - verify(tx, never()).createObject(eq(mockModel), isA(RequestScope.class)); - verify(tx, never()).flush(isA(RequestScope.class)); - verify(tx, never()).commit(isA(RequestScope.class)); - verify(tx).close(); - } - - @Test - public void testElidePatchExtensionUpdate() throws Exception { - DataStore store = mock(DataStore.class); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - FieldTestModel mockModel = mock(FieldTestModel.class); - - Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); - - String body = "[{\"op\": \"replace\",\"path\": \"/testModel/1\",\"value\":{" - + "\"type\":\"testModel\",\"id\": \"1\",\"attributes\": {\"field\":\"Foo\"}}}]"; - - dictionary.setValue(mockModel, "id", "1"); - when(store.beginTransaction()).thenReturn(tx); - when(tx.loadObject(any(), any(), isA(RequestScope.class))).thenReturn(mockModel); - - String contentType = JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; - ElideResponse response = - elide.patch(baseUrl, contentType, contentType, "/", body, null, NO_VERSION); - assertEquals(HttpStatus.SC_OK, response.getResponseCode()); - assertEquals("[{\"data\":null}]", response.getBody()); - - verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PRESECURITY)); - verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PREFLUSH)); - verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PRECOMMIT)); - verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(POSTCOMMIT)); - verify(mockModel, never()).classCallback(eq(CREATE), any()); - verify(mockModel, never()).classCallback(eq(DELETE), any()); - - verify(mockModel, never()).classAllFieldsCallback(any(), any()); - verify(mockModel, never()).classAllFieldsCallback(eq(CREATE), eq(PRECOMMIT)); - - verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PRESECURITY), any()); - verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PREFLUSH), any()); - verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PRECOMMIT), any()); - verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(POSTCOMMIT), any()); - verify(mockModel, never()).attributeCallback(eq(CREATE), any(), any()); - verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); - - verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); - verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); - verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); - - verify(tx).preCommit(any()); - - //Twice because the patch extension request is broken into attributes & relationships separately. - verify(tx, times(2)).loadObject(any(), any(), isA(RequestScope.class)); - verify(tx).flush(isA(RequestScope.class)); - verify(tx).commit(isA(RequestScope.class)); - verify(tx).close(); -} + @Test + public void failElidePatchExtensionCreate() throws Exception { + DataStore store = mock(DataStore.class); + DataStoreTransaction tx = mock(DataStoreTransaction.class); + FieldTestModel mockModel = mock(FieldTestModel.class); + + Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); + + String body = "[{\"op\": \"add\",\"path\": \"/testModel\",\"value\":{" + + "\"type\":\"testModel\",\"attributes\": {\"field\":\"Foo\"}}}]"; + + RequestScope scope = buildRequestScope(dictionary, tx); + when(store.beginTransaction()).thenReturn(tx); + when(tx.createNewObject(ClassType.of(FieldTestModel.class), scope)).thenReturn(mockModel); + + String contentType = JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; + ElideResponse response = elide.patch(baseUrl, contentType, contentType, "/", body, null, NO_VERSION); + assertEquals(HttpStatus.SC_BAD_REQUEST, response.getResponseCode()); + assertEquals( + "[{\"errors\":[{\"detail\":\"Bad Request Body'Patch extension requires all objects to have an assigned ID (temporary or permanent) when assigning relationships.'\",\"status\":\"400\"}]}]", + response.getBody()); + + verify(mockModel, never()).classCallback(eq(CREATE), eq(PRESECURITY)); + verify(mockModel, never()).classCallback(eq(CREATE), eq(PREFLUSH)); + verify(mockModel, never()).classCallback(eq(CREATE), eq(PRECOMMIT)); + verify(mockModel, never()).classCallback(eq(CREATE), eq(POSTCOMMIT)); + verify(mockModel, never()).classCallback(eq(UPDATE), any()); + verify(mockModel, never()).classCallback(eq(DELETE), any()); + + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + verify(mockModel, never()).classAllFieldsCallback(eq(CREATE), eq(PRECOMMIT)); + + verify(mockModel, never()).attributeCallback(eq(CREATE), eq(PRESECURITY), any()); + verify(mockModel, never()).attributeCallback(eq(CREATE), eq(PREFLUSH), any()); + verify(mockModel, never()).attributeCallback(eq(CREATE), eq(PRECOMMIT), any()); + verify(mockModel, never()).attributeCallback(eq(CREATE), eq(POSTCOMMIT), any()); + verify(mockModel, never()).attributeCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); + + verify(mockModel, never()).relationCallback(eq(CREATE), eq(PRESECURITY), any()); + verify(mockModel, never()).relationCallback(eq(CREATE), eq(PREFLUSH), any()); + verify(mockModel, never()).relationCallback(eq(CREATE), eq(PRECOMMIT), any()); + verify(mockModel, never()).relationCallback(eq(CREATE), eq(POSTCOMMIT), any()); + verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); + + verify(tx, never()).preCommit(any()); + verify(tx, never()).createObject(eq(mockModel), isA(RequestScope.class)); + verify(tx, never()).flush(isA(RequestScope.class)); + verify(tx, never()).commit(isA(RequestScope.class)); + verify(tx).close(); + } + + @Test + public void testElidePatchExtensionUpdate() throws Exception { + DataStore store = mock(DataStore.class); + DataStoreTransaction tx = mock(DataStoreTransaction.class); + FieldTestModel mockModel = mock(FieldTestModel.class); + + Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); + + String body = "[{\"op\": \"replace\",\"path\": \"/testModel/1\",\"value\":{" + + "\"type\":\"testModel\",\"id\": \"1\",\"attributes\": {\"field\":\"Foo\"}}}]"; + + dictionary.setValue(mockModel, "id", "1"); + when(store.beginTransaction()).thenReturn(tx); + when(tx.loadObject(any(), any(), isA(RequestScope.class))).thenReturn(mockModel); + + String contentType = JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; + ElideResponse response = elide.patch(baseUrl, contentType, contentType, "/", body, null, NO_VERSION); + assertEquals(HttpStatus.SC_OK, response.getResponseCode()); + assertEquals("[{\"data\":null}]", response.getBody()); + + verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PRESECURITY)); + verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PREFLUSH)); + verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PRECOMMIT)); + verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(POSTCOMMIT)); + verify(mockModel, never()).classCallback(eq(CREATE), any()); + verify(mockModel, never()).classCallback(eq(DELETE), any()); + + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + verify(mockModel, never()).classAllFieldsCallback(eq(CREATE), eq(PRECOMMIT)); + + verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PRESECURITY), any()); + verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PREFLUSH), any()); + verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PRECOMMIT), any()); + verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(POSTCOMMIT), any()); + verify(mockModel, never()).attributeCallback(eq(CREATE), any(), any()); + verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); + + verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); + verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); + + verify(tx).preCommit(any()); + + // Twice because the patch extension request is broken into attributes & + // relationships separately. + verify(tx, times(2)).loadObject(any(), any(), isA(RequestScope.class)); + verify(tx).flush(isA(RequestScope.class)); + verify(tx).commit(isA(RequestScope.class)); + verify(tx).close(); + } + + @Test + public void testElidePatchExtensionDelete() throws Exception { + DataStore store = mock(DataStore.class); + DataStoreTransaction tx = mock(DataStoreTransaction.class); + FieldTestModel mockModel = mock(FieldTestModel.class); + + Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); + + String body = "[{\"op\": \"remove\",\"path\": \"/testModel\",\"value\":{" + + "\"type\":\"testModel\",\"id\": \"1\"}}]"; + + dictionary.setValue(mockModel, "id", "1"); + when(store.beginTransaction()).thenReturn(tx); + when(tx.loadObject(any(), any(), isA(RequestScope.class))).thenReturn(mockModel); + + String contentType = JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; + ElideResponse response = elide.patch(baseUrl, contentType, contentType, "/", body, null, NO_VERSION); + assertEquals(HttpStatus.SC_OK, response.getResponseCode()); + + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + + verify(mockModel, never()).classCallback(eq(UPDATE), any()); + verify(mockModel, never()).classCallback(eq(CREATE), any()); + verify(mockModel, times(1)).classCallback(eq(DELETE), eq(PRESECURITY)); + verify(mockModel, times(1)).classCallback(eq(DELETE), eq(PREFLUSH)); + verify(mockModel, times(1)).classCallback(eq(DELETE), eq(PRECOMMIT)); + verify(mockModel, times(1)).classCallback(eq(DELETE), eq(POSTCOMMIT)); + + verify(mockModel, never()).attributeCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).attributeCallback(eq(CREATE), any(), any()); + verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); + + // TODO - Read should not be called for a delete. + verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); + verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); + + verify(tx).preCommit(any()); + verify(tx).delete(eq(mockModel), isA(RequestScope.class)); + verify(tx).flush(isA(RequestScope.class)); + verify(tx).commit(isA(RequestScope.class)); + verify(tx).close(); + } + + public void testElidePatchFailure() throws Exception { + DataStore store = mock(DataStore.class); + DataStoreTransaction tx = mock(DataStoreTransaction.class); + FieldTestModel mockModel = mock(FieldTestModel.class); + + Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); + + String body = "{\"data\": {\"type\":\"testModel\",\"id\":\"1\",\"attributes\": {\"field\":\"Foo\"}}}"; + + dictionary.setValue(mockModel, "id", "1"); + when(store.beginTransaction()).thenReturn(tx); + when(tx.loadObject(isA(EntityProjection.class), any(), isA(RequestScope.class))).thenReturn(mockModel); + doThrow(ConstraintViolationException.class).when(tx).flush(any()); + + String contentType = JSONAPI_CONTENT_TYPE; + ElideResponse response = elide.patch(baseUrl, contentType, contentType, "/testModel/1", body, null, NO_VERSION); + assertEquals(HttpStatus.SC_BAD_REQUEST, response.getResponseCode()); + assertEquals("{\"errors\":[{\"detail\":\"Constraint violation\"}]}", response.getBody()); + + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + + verify(mockModel, never()).classCallback(eq(CREATE), any()); + verify(mockModel, never()).classCallback(eq(DELETE), any()); + + verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PRESECURITY)); + verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PREFLUSH)); + verify(mockModel, never()).classCallback(eq(UPDATE), eq(PRECOMMIT)); + verify(mockModel, never()).classCallback(eq(UPDATE), eq(POSTCOMMIT)); + + verify(mockModel, never()).attributeCallback(eq(CREATE), any(), any()); + verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); + verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PRESECURITY), any()); + verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PREFLUSH), any()); + verify(mockModel, never()).attributeCallback(eq(UPDATE), eq(PRECOMMIT), any()); + verify(mockModel, never()).attributeCallback(eq(UPDATE), eq(POSTCOMMIT), any()); + + verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); + verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); + + verify(tx).preCommit(any()); + verify(tx).save(eq(mockModel), isA(RequestScope.class)); + verify(tx).flush(isA(RequestScope.class)); + verify(tx, never()).commit(isA(RequestScope.class)); + verify(tx).close(); + } + + @Test + public void testElideAtomicOperationsExtensionCreate() throws Exception { + DataStore store = mock(DataStore.class); + DataStoreTransaction tx = mock(DataStoreTransaction.class); + FieldTestModel mockModel = mock(FieldTestModel.class); + + Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); + + String body = """ + { + "atomic:operations": [{ + "op": "add", + "href": "/testModel", + "data": { + "type": "testModel", + "id": "1", + "attributes": { + "field": "Foo" + } + } + }] + } + """; + when(store.beginTransaction()).thenReturn(tx); + when(tx.createNewObject(eq(ClassType.of(FieldTestModel.class)), any())).thenReturn(mockModel); + + String contentType = JsonApi.AtomicOperations.MEDIA_TYPE; + ElideResponse response = elide.operations(baseUrl, contentType, contentType, "/", body, null, NO_VERSION); + assertEquals(HttpStatus.SC_OK, response.getResponseCode()); + + verify(mockModel, times(1)).classCallback(eq(CREATE), eq(PRESECURITY)); + verify(mockModel, times(1)).classCallback(eq(CREATE), eq(PREFLUSH)); + verify(mockModel, times(1)).classCallback(eq(CREATE), eq(PRECOMMIT)); + verify(mockModel, times(1)).classCallback(eq(CREATE), eq(POSTCOMMIT)); + verify(mockModel, never()).classCallback(eq(UPDATE), any()); + verify(mockModel, never()).classCallback(eq(DELETE), any()); + + verify(mockModel, times(2)).classAllFieldsCallback(any(), any()); + verify(mockModel, times(2)).classAllFieldsCallback(eq(CREATE), eq(PRECOMMIT)); + + verify(mockModel, times(1)).attributeCallback(eq(CREATE), eq(PRESECURITY), any()); + verify(mockModel, times(1)).attributeCallback(eq(CREATE), eq(PREFLUSH), any()); + verify(mockModel, times(1)).attributeCallback(eq(CREATE), eq(PRECOMMIT), any()); + verify(mockModel, times(1)).attributeCallback(eq(CREATE), eq(POSTCOMMIT), any()); + verify(mockModel, never()).attributeCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); + + verify(mockModel, times(1)).relationCallback(eq(CREATE), eq(PRESECURITY), any()); + verify(mockModel, times(1)).relationCallback(eq(CREATE), eq(PREFLUSH), any()); + verify(mockModel, times(1)).relationCallback(eq(CREATE), eq(PRECOMMIT), any()); + verify(mockModel, times(1)).relationCallback(eq(CREATE), eq(POSTCOMMIT), any()); + verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); + + verify(tx).preCommit(any()); + verify(tx, times(1)).createObject(eq(mockModel), isA(RequestScope.class)); + verify(tx).flush(isA(RequestScope.class)); + verify(tx).commit(isA(RequestScope.class)); + verify(tx).close(); + } + + @Test + public void testElideAtomicOperationsExtensionCreateRef() throws Exception { + DataStore store = mock(DataStore.class); + DataStoreTransaction tx = mock(DataStoreTransaction.class); + FieldTestModel mockModel = mock(FieldTestModel.class); + + Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); + + String body = """ + { + "atomic:operations": [{ + "op": "add", + "data": { + "type": "testModel", + "id": "1", + "attributes": { + "field": "Foo" + } + } + }] + } + """; + when(store.beginTransaction()).thenReturn(tx); + when(tx.createNewObject(eq(ClassType.of(FieldTestModel.class)), any())).thenReturn(mockModel); + + String contentType = JsonApi.AtomicOperations.MEDIA_TYPE; + ElideResponse response = elide.operations(baseUrl, contentType, contentType, "/", body, null, NO_VERSION); + assertEquals(HttpStatus.SC_OK, response.getResponseCode()); + + verify(mockModel, times(1)).classCallback(eq(CREATE), eq(PRESECURITY)); + verify(mockModel, times(1)).classCallback(eq(CREATE), eq(PREFLUSH)); + verify(mockModel, times(1)).classCallback(eq(CREATE), eq(PRECOMMIT)); + verify(mockModel, times(1)).classCallback(eq(CREATE), eq(POSTCOMMIT)); + verify(mockModel, never()).classCallback(eq(UPDATE), any()); + verify(mockModel, never()).classCallback(eq(DELETE), any()); + + verify(mockModel, times(2)).classAllFieldsCallback(any(), any()); + verify(mockModel, times(2)).classAllFieldsCallback(eq(CREATE), eq(PRECOMMIT)); + + verify(mockModel, times(1)).attributeCallback(eq(CREATE), eq(PRESECURITY), any()); + verify(mockModel, times(1)).attributeCallback(eq(CREATE), eq(PREFLUSH), any()); + verify(mockModel, times(1)).attributeCallback(eq(CREATE), eq(PRECOMMIT), any()); + verify(mockModel, times(1)).attributeCallback(eq(CREATE), eq(POSTCOMMIT), any()); + verify(mockModel, never()).attributeCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); + + verify(mockModel, times(1)).relationCallback(eq(CREATE), eq(PRESECURITY), any()); + verify(mockModel, times(1)).relationCallback(eq(CREATE), eq(PREFLUSH), any()); + verify(mockModel, times(1)).relationCallback(eq(CREATE), eq(PRECOMMIT), any()); + verify(mockModel, times(1)).relationCallback(eq(CREATE), eq(POSTCOMMIT), any()); + verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); + + verify(tx).preCommit(any()); + verify(tx, times(1)).createObject(eq(mockModel), isA(RequestScope.class)); + verify(tx).flush(isA(RequestScope.class)); + verify(tx).commit(isA(RequestScope.class)); + verify(tx).close(); + } + + @Test + public void failElideAtomicOperationsExtensionCreate() throws Exception { + DataStore store = mock(DataStore.class); + DataStoreTransaction tx = mock(DataStoreTransaction.class); + FieldTestModel mockModel = mock(FieldTestModel.class); + + Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); + + String body = """ + { + "atomic:operations": [{ + "op": "add", + "href": "/testModel", + "data": { + "type": "testModel", + "attributes": { + "field": "Foo" + } + } + }] + } + """; + + RequestScope scope = buildRequestScope(dictionary, tx); + when(store.beginTransaction()).thenReturn(tx); + when(tx.createNewObject(ClassType.of(FieldTestModel.class), scope)).thenReturn(mockModel); - @Test - public void testElidePatchExtensionDelete() throws Exception { - DataStore store = mock(DataStore.class); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - FieldTestModel mockModel = mock(FieldTestModel.class); - - Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); - - String body = "[{\"op\": \"remove\",\"path\": \"/testModel\",\"value\":{" - + "\"type\":\"testModel\",\"id\": \"1\"}}]"; - - dictionary.setValue(mockModel, "id", "1"); - when(store.beginTransaction()).thenReturn(tx); - when(tx.loadObject(any(), any(), isA(RequestScope.class))).thenReturn(mockModel); - - String contentType = JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; - ElideResponse response = - elide.patch(baseUrl, contentType, contentType, "/", body, null, NO_VERSION); - assertEquals(HttpStatus.SC_OK, response.getResponseCode()); - - verify(mockModel, never()).classAllFieldsCallback(any(), any()); - - verify(mockModel, never()).classCallback(eq(UPDATE), any()); - verify(mockModel, never()).classCallback(eq(CREATE), any()); - verify(mockModel, times(1)).classCallback(eq(DELETE), eq(PRESECURITY)); - verify(mockModel, times(1)).classCallback(eq(DELETE), eq(PREFLUSH)); - verify(mockModel, times(1)).classCallback(eq(DELETE), eq(PRECOMMIT)); - verify(mockModel, times(1)).classCallback(eq(DELETE), eq(POSTCOMMIT)); - - verify(mockModel, never()).attributeCallback(eq(UPDATE), any(), any()); - verify(mockModel, never()).attributeCallback(eq(CREATE), any(), any()); - verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); - - // TODO - Read should not be called for a delete. - verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); - verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); - verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); - - verify(tx).preCommit(any()); - verify(tx).delete(eq(mockModel), isA(RequestScope.class)); - verify(tx).flush(isA(RequestScope.class)); - verify(tx).commit(isA(RequestScope.class)); - verify(tx).close(); - } - - public void testElidePatchFailure() throws Exception { - DataStore store = mock(DataStore.class); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - FieldTestModel mockModel = mock(FieldTestModel.class); - - Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); - - String body = "{\"data\": {\"type\":\"testModel\",\"id\":\"1\",\"attributes\": {\"field\":\"Foo\"}}}"; - - dictionary.setValue(mockModel, "id", "1"); - when(store.beginTransaction()).thenReturn(tx); - when(tx.loadObject(isA(EntityProjection.class), any(), isA(RequestScope.class))).thenReturn(mockModel); - doThrow(ConstraintViolationException.class).when(tx).flush(any()); - - String contentType = JSONAPI_CONTENT_TYPE; - ElideResponse response = - elide.patch(baseUrl, contentType, contentType, "/testModel/1", body, null, NO_VERSION); - assertEquals(HttpStatus.SC_BAD_REQUEST, response.getResponseCode()); - assertEquals( - "{\"errors\":[{\"detail\":\"Constraint violation\"}]}", - response.getBody()); - - verify(mockModel, never()).classAllFieldsCallback(any(), any()); - - verify(mockModel, never()).classCallback(eq(CREATE), any()); - verify(mockModel, never()).classCallback(eq(DELETE), any()); - - verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PRESECURITY)); - verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PREFLUSH)); - verify(mockModel, never()).classCallback(eq(UPDATE), eq(PRECOMMIT)); - verify(mockModel, never()).classCallback(eq(UPDATE), eq(POSTCOMMIT)); - - verify(mockModel, never()).attributeCallback(eq(CREATE), any(), any()); - verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); - verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PRESECURITY), any()); - verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PREFLUSH), any()); - verify(mockModel, never()).attributeCallback(eq(UPDATE), eq(PRECOMMIT), any()); - verify(mockModel, never()).attributeCallback(eq(UPDATE), eq(POSTCOMMIT), any()); - - verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); - verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); - verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); - - verify(tx).preCommit(any()); - verify(tx).save(eq(mockModel), isA(RequestScope.class)); - verify(tx).flush(isA(RequestScope.class)); - verify(tx, never()).commit(isA(RequestScope.class)); - verify(tx).close(); - } + String contentType = JsonApi.AtomicOperations.MEDIA_TYPE; + ElideResponse response = elide.operations(baseUrl, contentType, contentType, "/", body, null, NO_VERSION); + assertEquals(HttpStatus.SC_BAD_REQUEST, response.getResponseCode()); + String expected = """ + [{"errors":[{"detail":"Bad Request Body'Atomic Operations extension requires all objects to have an assigned ID (temporary or permanent) when assigning relationships.'","status":"400"}]}]"""; + assertEquals(expected, response.getBody()); + + verify(mockModel, never()).classCallback(eq(CREATE), eq(PRESECURITY)); + verify(mockModel, never()).classCallback(eq(CREATE), eq(PREFLUSH)); + verify(mockModel, never()).classCallback(eq(CREATE), eq(PRECOMMIT)); + verify(mockModel, never()).classCallback(eq(CREATE), eq(POSTCOMMIT)); + verify(mockModel, never()).classCallback(eq(UPDATE), any()); + verify(mockModel, never()).classCallback(eq(DELETE), any()); + + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + verify(mockModel, never()).classAllFieldsCallback(eq(CREATE), eq(PRECOMMIT)); + + verify(mockModel, never()).attributeCallback(eq(CREATE), eq(PRESECURITY), any()); + verify(mockModel, never()).attributeCallback(eq(CREATE), eq(PREFLUSH), any()); + verify(mockModel, never()).attributeCallback(eq(CREATE), eq(PRECOMMIT), any()); + verify(mockModel, never()).attributeCallback(eq(CREATE), eq(POSTCOMMIT), any()); + verify(mockModel, never()).attributeCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); + + verify(mockModel, never()).relationCallback(eq(CREATE), eq(PRESECURITY), any()); + verify(mockModel, never()).relationCallback(eq(CREATE), eq(PREFLUSH), any()); + verify(mockModel, never()).relationCallback(eq(CREATE), eq(PRECOMMIT), any()); + verify(mockModel, never()).relationCallback(eq(CREATE), eq(POSTCOMMIT), any()); + verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); + + verify(tx, never()).preCommit(any()); + verify(tx, never()).createObject(eq(mockModel), isA(RequestScope.class)); + verify(tx, never()).flush(isA(RequestScope.class)); + verify(tx, never()).commit(isA(RequestScope.class)); + verify(tx).close(); + } + + @Test + public void testElideAtomicOperationsExtensionUpdate() throws Exception { + DataStore store = mock(DataStore.class); + DataStoreTransaction tx = mock(DataStoreTransaction.class); + FieldTestModel mockModel = mock(FieldTestModel.class); + + Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); + + String body = """ + { + "atomic:operations": [{ + "op": "update", + "href": "/testModel/1", + "data": { + "type": "testModel", + "id": "1", + "attributes": { + "field": "Foo" + } + } + }] + } + """; + + dictionary.setValue(mockModel, "id", "1"); + when(store.beginTransaction()).thenReturn(tx); + when(tx.loadObject(any(), any(), isA(RequestScope.class))).thenReturn(mockModel); + + String contentType = JsonApi.AtomicOperations.MEDIA_TYPE; + ElideResponse response = elide.operations(baseUrl, contentType, contentType, "/", body, null, NO_VERSION); + assertEquals(HttpStatus.SC_OK, response.getResponseCode()); + + String expected = """ + {"atomic:results":[{"data":null}]}"""; + assertEquals(expected, response.getBody()); + + verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PRESECURITY)); + verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PREFLUSH)); + verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PRECOMMIT)); + verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(POSTCOMMIT)); + verify(mockModel, never()).classCallback(eq(CREATE), any()); + verify(mockModel, never()).classCallback(eq(DELETE), any()); + + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + verify(mockModel, never()).classAllFieldsCallback(eq(CREATE), eq(PRECOMMIT)); + + verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PRESECURITY), any()); + verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PREFLUSH), any()); + verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PRECOMMIT), any()); + verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(POSTCOMMIT), any()); + verify(mockModel, never()).attributeCallback(eq(CREATE), any(), any()); + verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); + + verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); + verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); + + verify(tx).preCommit(any()); + + // Twice because the patch extension request is broken into attributes & + // relationships separately. + verify(tx, times(2)).loadObject(any(), any(), isA(RequestScope.class)); + verify(tx).flush(isA(RequestScope.class)); + verify(tx).commit(isA(RequestScope.class)); + verify(tx).close(); + } + + @Test + public void testElideAtomicOperationsExtensionUpdateRef() throws Exception { + DataStore store = mock(DataStore.class); + DataStoreTransaction tx = mock(DataStoreTransaction.class); + FieldTestModel mockModel = mock(FieldTestModel.class); + + Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); + + String body = """ + { + "atomic:operations": [{ + "op": "update", + "data": { + "type": "testModel", + "id": "1", + "attributes": { + "field": "Foo" + } + } + }] + } + """; + + dictionary.setValue(mockModel, "id", "1"); + when(store.beginTransaction()).thenReturn(tx); + when(tx.loadObject(any(), any(), isA(RequestScope.class))).thenReturn(mockModel); + + String contentType = JsonApi.AtomicOperations.MEDIA_TYPE; + ElideResponse response = elide.operations(baseUrl, contentType, contentType, "/", body, null, NO_VERSION); + assertEquals(HttpStatus.SC_OK, response.getResponseCode()); + + String expected = """ + {"atomic:results":[{"data":null}]}"""; + assertEquals(expected, response.getBody()); + + verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PRESECURITY)); + verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PREFLUSH)); + verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PRECOMMIT)); + verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(POSTCOMMIT)); + verify(mockModel, never()).classCallback(eq(CREATE), any()); + verify(mockModel, never()).classCallback(eq(DELETE), any()); + + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + verify(mockModel, never()).classAllFieldsCallback(eq(CREATE), eq(PRECOMMIT)); + + verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PRESECURITY), any()); + verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PREFLUSH), any()); + verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PRECOMMIT), any()); + verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(POSTCOMMIT), any()); + verify(mockModel, never()).attributeCallback(eq(CREATE), any(), any()); + verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); + + verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); + verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); + + verify(tx).preCommit(any()); + + // Twice because the patch extension request is broken into attributes & + // relationships separately. + verify(tx, times(2)).loadObject(any(), any(), isA(RequestScope.class)); + verify(tx).flush(isA(RequestScope.class)); + verify(tx).commit(isA(RequestScope.class)); + verify(tx).close(); + } + + @Test + public void testElideAtomicOperationsExtensionDelete() throws Exception { + DataStore store = mock(DataStore.class); + DataStoreTransaction tx = mock(DataStoreTransaction.class); + FieldTestModel mockModel = mock(FieldTestModel.class); + + Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); + + String body = """ + { + "atomic:operations": [{ + "op": "remove", + "href": "/testModel", + "data": { + "type": "testModel", + "id": "1" + } + }] + } + """; + dictionary.setValue(mockModel, "id", "1"); + when(store.beginTransaction()).thenReturn(tx); + when(tx.loadObject(any(), any(), isA(RequestScope.class))).thenReturn(mockModel); + + String contentType = JsonApi.AtomicOperations.MEDIA_TYPE; + ElideResponse response = elide.operations(baseUrl, contentType, contentType, "/", body, null, NO_VERSION); + assertEquals(HttpStatus.SC_OK, response.getResponseCode()); + + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + + verify(mockModel, never()).classCallback(eq(UPDATE), any()); + verify(mockModel, never()).classCallback(eq(CREATE), any()); + verify(mockModel, times(1)).classCallback(eq(DELETE), eq(PRESECURITY)); + verify(mockModel, times(1)).classCallback(eq(DELETE), eq(PREFLUSH)); + verify(mockModel, times(1)).classCallback(eq(DELETE), eq(PRECOMMIT)); + verify(mockModel, times(1)).classCallback(eq(DELETE), eq(POSTCOMMIT)); + + verify(mockModel, never()).attributeCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).attributeCallback(eq(CREATE), any(), any()); + verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); + + // TODO - Read should not be called for a delete. + verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); + verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); + + verify(tx).preCommit(any()); + verify(tx).delete(eq(mockModel), isA(RequestScope.class)); + verify(tx).flush(isA(RequestScope.class)); + verify(tx).commit(isA(RequestScope.class)); + verify(tx).close(); + } + + @Test + public void testElideAtomicOperationsExtensionDeleteRef() throws Exception { + DataStore store = mock(DataStore.class); + DataStoreTransaction tx = mock(DataStoreTransaction.class); + FieldTestModel mockModel = mock(FieldTestModel.class); + + Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); + + String body = """ + { + "atomic:operations": [{ + "op": "remove", + "ref": { + "type": "testModel", + "id": "1" + } + }] + } + """; + dictionary.setValue(mockModel, "id", "1"); + when(store.beginTransaction()).thenReturn(tx); + when(tx.loadObject(any(), any(), isA(RequestScope.class))).thenReturn(mockModel); + + String contentType = JsonApi.AtomicOperations.MEDIA_TYPE; + ElideResponse response = elide.operations(baseUrl, contentType, contentType, "/", body, null, NO_VERSION); + assertEquals(HttpStatus.SC_OK, response.getResponseCode()); + + verify(mockModel, never()).classAllFieldsCallback(any(), any()); + + verify(mockModel, never()).classCallback(eq(UPDATE), any()); + verify(mockModel, never()).classCallback(eq(CREATE), any()); + verify(mockModel, times(1)).classCallback(eq(DELETE), eq(PRESECURITY)); + verify(mockModel, times(1)).classCallback(eq(DELETE), eq(PREFLUSH)); + verify(mockModel, times(1)).classCallback(eq(DELETE), eq(PRECOMMIT)); + verify(mockModel, times(1)).classCallback(eq(DELETE), eq(POSTCOMMIT)); + + verify(mockModel, never()).attributeCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).attributeCallback(eq(CREATE), any(), any()); + verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); + + // TODO - Read should not be called for a delete. + verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); + verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); + verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); + + verify(tx).preCommit(any()); + verify(tx).delete(eq(mockModel), isA(RequestScope.class)); + verify(tx).flush(isA(RequestScope.class)); + verify(tx).commit(isA(RequestScope.class)); + verify(tx).close(); + } @Test public void testCreate() { diff --git a/elide-core/src/test/java/com/yahoo/elide/core/utils/ClassScannerTest.java b/elide-core/src/test/java/com/yahoo/elide/core/utils/ClassScannerTest.java index b383b09c4f..6a93c07795 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/utils/ClassScannerTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/utils/ClassScannerTest.java @@ -33,21 +33,21 @@ public void testGetAllClasses() { @Test public void testGetAnnotatedClasses() { Set> classes = scanner.getAnnotatedClasses("example", Include.class); - assertEquals(31, classes.size(), "Actual: " + classes); + assertEquals(32, classes.size(), "Actual: " + classes); classes.forEach(cls -> assertTrue(cls.isAnnotationPresent(Include.class))); } @Test public void testGetAllAnnotatedClasses() { Set> classes = scanner.getAnnotatedClasses(Include.class); - assertEquals(43, classes.size(), "Actual: " + classes); + assertEquals(44, classes.size(), "Actual: " + classes); classes.forEach(cls -> assertTrue(cls.isAnnotationPresent(Include.class))); } @Test public void testGetAnyAnnotatedClasses() { Set> classes = scanner.getAnnotatedClasses(Include.class, Entity.class); - assertEquals(54, classes.size()); + assertEquals(55, classes.size()); for (Class cls : classes) { assertTrue(cls.isAnnotationPresent(Include.class) || cls.isAnnotationPresent(Entity.class)); diff --git a/elide-core/src/test/java/com/yahoo/elide/jsonapi/extensions/JsonApiAtomicOperationsMapperTest.java b/elide-core/src/test/java/com/yahoo/elide/jsonapi/extensions/JsonApiAtomicOperationsMapperTest.java new file mode 100644 index 0000000000..453f40e10b --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/jsonapi/extensions/JsonApiAtomicOperationsMapperTest.java @@ -0,0 +1,142 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.jsonapi.extensions; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.yahoo.elide.jsonapi.models.JsonApiDocument; +import com.yahoo.elide.jsonapi.models.Operations; +import com.yahoo.elide.jsonapi.models.Resource; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.junit.jupiter.api.Test; + +/** + * Tests for JsonApiAtomicOperationsMapper. + */ +class JsonApiAtomicOperationsMapperTest { + + @Test + void readDataCollection() throws JsonProcessingException { + JsonApiAtomicOperationsMapper mapper = new JsonApiAtomicOperationsMapper(new ObjectMapper()); + String operationsDoc = """ + { + "atomic:operations": [{ + "op": "update", + "ref": { + "type": "articles", + "id": "1", + "relationship": "tags" + }, + "data": [ + { "type": "tags", "id": "2" }, + { "type": "tags", "id": "3" } + ] + }] + } + """; + Operations operations = mapper.readDoc(operationsDoc); + JsonApiDocument document = mapper.readData(operations.getOperations().get(0).getData()); + assertEquals(2, document.getData().get().size()); + } + + @Test + void readDataSingleId() throws JsonProcessingException { + JsonApiAtomicOperationsMapper mapper = new JsonApiAtomicOperationsMapper(new ObjectMapper()); + String operationsDoc = """ + { + "atomic:operations": [{ + "op": "update", + "ref": { + "type": "articles", + "id": "13", + "relationship": "author" + }, + "data": { + "type": "people", + "id": "9" + } + }] + } + """; + Operations operations = mapper.readDoc(operationsDoc); + JsonApiDocument document = mapper.readData(operations.getOperations().get(0).getData()); + assertEquals(1, document.getData().get().size()); + Resource resource = document.getData().get().iterator().next(); + assertEquals("people", resource.getType()); + assertEquals("9", resource.getId()); + } + + @Test + void readDataSingleLid() throws JsonProcessingException { + JsonApiAtomicOperationsMapper mapper = new JsonApiAtomicOperationsMapper(new ObjectMapper()); + String operationsDoc = """ + { + "atomic:operations": [{ + "op": "update", + "ref": { + "type": "articles", + "lid": "13", + "relationship": "author" + }, + "data": { + "type": "people", + "lid": "9" + } + }] + } + """; + Operations operations = mapper.readDoc(operationsDoc); + JsonApiDocument document = mapper.readData(operations.getOperations().get(0).getData()); + assertEquals(1, document.getData().get().size()); + Resource resource = document.getData().get().iterator().next(); + assertEquals("people", resource.getType()); + assertEquals("9", resource.getId()); + } + + @Test + void readDataNull() throws JsonProcessingException { + JsonApiAtomicOperationsMapper mapper = new JsonApiAtomicOperationsMapper(new ObjectMapper()); + String operationsDoc = """ + { + "atomic:operations": [{ + "op": "update", + "ref": { + "type": "articles", + "id": "13", + "relationship": "author" + }, + "data": null + }] + } + """; + Operations operations = mapper.readDoc(operationsDoc); + JsonApiDocument document = mapper.readData(operations.getOperations().get(0).getData()); + assertEquals(0, document.getData().get().size()); + } + + @Test + void readNull() throws JsonProcessingException { + JsonApiAtomicOperationsMapper mapper = new JsonApiAtomicOperationsMapper(new ObjectMapper()); + String operationsDoc = """ + { + "atomic:operations": [{ + "op": "update", + "ref": { + "type": "articles", + "id": "13", + "relationship": "author" + } + }] + } + """; + Operations operations = mapper.readDoc(operationsDoc); + JsonApiDocument document = mapper.readData(operations.getOperations().get(0).getData()); + assertNull(document.getData()); + } +} diff --git a/elide-core/src/test/java/com/yahoo/elide/jsonapi/extensions/JsonApiAtomicOperationsTest.java b/elide-core/src/test/java/com/yahoo/elide/jsonapi/extensions/JsonApiAtomicOperationsTest.java new file mode 100644 index 0000000000..3e8c2a79f2 --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/jsonapi/extensions/JsonApiAtomicOperationsTest.java @@ -0,0 +1,651 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.jsonapi.extensions; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.yahoo.elide.ElideSettings; +import com.yahoo.elide.ElideSettingsBuilder; +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.datastore.inmemory.HashMapDataStore; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.exceptions.InvalidEntityBodyException; +import com.yahoo.elide.core.exceptions.JsonApiAtomicOperationsException; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.jsonapi.JsonApiMapper; +import com.yahoo.elide.jsonapi.models.Resource; +import com.yahoo.elide.jsonapi.models.Results; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import example.Book; +import example.Company; +import example.Person; + +import org.apache.commons.lang3.tuple.Pair; +import org.glassfish.jersey.internal.util.collection.ImmutableMultivaluedMap; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.UUID; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Tests for JsonApiAtomicOperations. + */ +public class JsonApiAtomicOperationsTest { + private DataStore dataStore; + private ElideSettings settings; + + @BeforeEach + void setup() { + this.dataStore = new HashMapDataStore(Arrays.asList(Book.class, Company.class, Person.class)); + EntityDictionary entityDictionary = EntityDictionary.builder().build(); + this.dataStore.populateEntityDictionary(entityDictionary); + this.settings = new ElideSettingsBuilder(this.dataStore).withEntityDictionary(entityDictionary) + .withJsonApiMapper(new JsonApiMapper()).build(); + } + + Supplier> doInTransaction( + Function>> callback) { + try (DataStoreTransaction transaction = this.dataStore.beginTransaction()) { + JsonApiAtomicOperationsRequestScope scope = new JsonApiAtomicOperationsRequestScope("https://elide.io", "", "", transaction, null, + UUID.randomUUID(), ImmutableMultivaluedMap.empty(), new HashMap<>(), settings); + Supplier> result = callback.apply(scope); + + scope.saveOrCreateObjects(); + transaction.commit(scope); + return result; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Test + void invalidOperationShouldThrow() { + String operationsDoc = """ + { + "atomic:operations": [{ + "op": "invalid", + "ref": { + "type": "articles", + "id": "13" + } + }] + }"""; + + doInTransaction(scope -> { + assertThrows(InvalidEntityBodyException.class, + () -> JsonApiAtomicOperations.processAtomicOperations(this.dataStore, null, operationsDoc, scope)); + try { + return JsonApiAtomicOperations.processAtomicOperations(this.dataStore, null, operationsDoc, scope); + } catch (InvalidEntityBodyException e) { + assertEquals("Bad Request Body'Invalid Atomic Operations extension operation code:invalid'", + e.getMessage()); + return null; + } + }); + } + + @Test + void invalidJsonShouldThrow() { + String operationsDoc = """ + {invalidjson"""; + doInTransaction(scope -> { + assertThrows(InvalidEntityBodyException.class, + () -> JsonApiAtomicOperations.processAtomicOperations(null, null, operationsDoc, scope)); + try { + return JsonApiAtomicOperations.processAtomicOperations(this.dataStore, null, operationsDoc, scope); + } catch (InvalidEntityBodyException e) { + assertEquals("Bad Request Body'{invalidjson'", e.getMessage()); + return null; + } + }); + } + + @Test + void invalidRefShouldThrow() { + String operationsDoc = """ + { + "atomic:operations": [{ + "op": "remove", + "ref": { + "id": "13" + } + }] + }"""; + + doInTransaction(scope -> { + assertThrows(JsonApiAtomicOperationsException.class, + () -> JsonApiAtomicOperations.processAtomicOperations(null, null, operationsDoc, scope)); + try { + return JsonApiAtomicOperations.processAtomicOperations(this.dataStore, null, operationsDoc, scope); + } catch (JsonApiAtomicOperationsException e) { + ObjectNode error = (ObjectNode) e.getErrorResponse().getValue().get(0).get("errors").get(0); + assertEquals("400", error.get("status").asText()); + assertEquals("Bad Request Body'Atomic Operations extension ref must specify the type member.'", + error.get("detail").asText()); + return null; + } + }); + } + + @Test + void createResourceShouldNotSpecifyRefShouldThrow() { + String operationsDoc = """ + { + "atomic:operations": [{ + "op": "add", + "ref": { + "id": "13", + "type": "group" + } + }] + }"""; + + doInTransaction(scope -> { + assertThrows(JsonApiAtomicOperationsException.class, + () -> JsonApiAtomicOperations.processAtomicOperations(null, null, operationsDoc, scope)); + try { + return JsonApiAtomicOperations.processAtomicOperations(this.dataStore, null, operationsDoc, scope); + } catch (JsonApiAtomicOperationsException e) { + ObjectNode error = (ObjectNode) e.getErrorResponse().getValue().get(0).get("errors").get(0); + assertEquals("400", error.get("status").asText()); + assertEquals("Bad Request Body'Atomic Operations extension add resource operation may only specify the href member.'", + error.get("detail").asText()); + return null; + } + }); + } + + @Test + void bothRefAndHrefShouldThrow() { + String operationsDoc = """ + { + "atomic:operations": [{ + "op": "remove", + "href": "/group/13", + "ref": { + "id": "13", + "type": "group" + } + }] + }"""; + + doInTransaction(scope -> { + assertThrows(JsonApiAtomicOperationsException.class, + () -> JsonApiAtomicOperations.processAtomicOperations(null, null, operationsDoc, scope)); + try { + return JsonApiAtomicOperations.processAtomicOperations(this.dataStore, null, operationsDoc, scope); + } catch (JsonApiAtomicOperationsException e) { + ObjectNode error = (ObjectNode) e.getErrorResponse().getValue().get(0).get("errors").get(0); + assertEquals("400", error.get("status").asText()); + assertEquals("Bad Request Body'Atomic Operations extension operation cannot contain both ref and href members.'", + error.get("detail").asText()); + return null; + } + }); + } + + @Test + void refWithBothIdAndLidShouldThrow() { + String operationsDoc = """ + { + "atomic:operations": [{ + "op": "remove", + "ref": { + "id": "13", + "lid": "6868e773-e05c-4ef5-8db7-0a493336fbb5", + "type": "group" + } + }] + }"""; + + doInTransaction(scope -> { + assertThrows(JsonApiAtomicOperationsException.class, + () -> JsonApiAtomicOperations.processAtomicOperations(null, null, operationsDoc, scope)); + try { + return JsonApiAtomicOperations.processAtomicOperations(this.dataStore, null, operationsDoc, scope); + } catch (JsonApiAtomicOperationsException e) { + ObjectNode error = (ObjectNode) e.getErrorResponse().getValue().get(0).get("errors").get(0); + assertEquals("400", error.get("status").asText()); + assertEquals("Bad Request Body'Atomic Operations extension ref cannot contain both id and lid members.'", + error.get("detail").asText()); + return null; + } + }); + } + + @Test + void noRefAndHrefShouldThrow() { + String operationsDoc = """ + { + "atomic:operations": [{ + "op": "add" + }] + }"""; + doInTransaction(scope -> { + assertThrows(JsonApiAtomicOperationsException.class, + () -> JsonApiAtomicOperations.processAtomicOperations(this.dataStore, null, operationsDoc, scope)); + try { + return JsonApiAtomicOperations.processAtomicOperations(this.dataStore, null, operationsDoc, scope); + } catch (JsonApiAtomicOperationsException e) { + ObjectNode error = (ObjectNode) e.getErrorResponse().getValue().get(0).get("errors").get(0); + assertEquals("400", error.get("status").asText()); + assertEquals( + "Bad Request Body'Atomic Operations extension operation requires either ref or href members to be specified.'", + error.get("detail").asText()); + return null; + } + }); + } + + @Test + void removeNoRefAndHrefShouldThrow() { + String operationsDoc = """ + { + "atomic:operations": [{ + "op": "remove", + "data": { + "type": "book", + "id": "13" + } + }] + }"""; + doInTransaction(scope -> { + assertThrows(JsonApiAtomicOperationsException.class, + () -> JsonApiAtomicOperations.processAtomicOperations(this.dataStore, null, operationsDoc, scope)); + try { + return JsonApiAtomicOperations.processAtomicOperations(this.dataStore, null, operationsDoc, scope); + } catch (JsonApiAtomicOperationsException e) { + ObjectNode error = (ObjectNode) e.getErrorResponse().getValue().get(0).get("errors").get(0); + assertEquals("400", error.get("status").asText()); + assertEquals( + "Bad Request Body'Atomic Operations extension operation requires either ref or href members to be specified.'", + error.get("detail").asText()); + return null; + } + }); + } + + @Test + void oneToOneDeleteUnknownCollectionShouldThrow() { + String operationsDoc = """ + { + "atomic:operations": [{ + "op": "update", + "ref": { + "type": "book", + "id": "13", + "relationship": "author" + }, + "data": null + }] + }"""; + doInTransaction(scope -> { + try { + return JsonApiAtomicOperations.processAtomicOperations(this.dataStore, null, operationsDoc, scope); + } catch (JsonApiAtomicOperationsException e) { + JsonNode error = e.getErrorResponse().getValue().get(0).get("errors").get(0); + assertEquals("404", error.get("status").asText()); + assertEquals("Unknown collection author", error.get("detail").asText()); + return null; + } + }); + } + + @Test + void addUpdateRemove() throws IOException { + // Add + Pair result = doInTransaction(scope -> { + String operationsDoc = """ + { + "atomic:operations": [{ + "op": "add", + "data": { + "type": "company", + "id": "1", + "attributes": { + "description": "Company Description" + } + } + }] + }"""; + return JsonApiAtomicOperations + .processAtomicOperations(this.dataStore, null, operationsDoc, scope); + }).get(); + + assertEquals(200, result.getKey()); + JsonNode data = result.getValue().get("atomic:results").get(0).get("data"); + assertEquals("company", data.get("type").asText()); + assertEquals("1", data.get("id").asText()); + + + doInTransaction(scope -> { + Company company = scope.getTransaction().loadObject(EntityProjection.builder().type(Company.class).build(), + "1", scope); + assertEquals("Company Description", company.getDescription()); + return null; + }); + + // Update + result = doInTransaction(scope -> { + String operationsDoc = """ + { + "atomic:operations": [{ + "op": "update", + "data": { + "type": "company", + "id": "1", + "attributes": { + "description": "Updated Company Description" + } + } + }] + }"""; + return JsonApiAtomicOperations + .processAtomicOperations(this.dataStore, null, operationsDoc, scope); + }).get(); + + assertEquals(200, result.getKey()); + data = result.getValue().get("atomic:results").get(0).get("data"); + assertTrue(data.isNull()); + + + doInTransaction(scope -> { + Company company = scope.getTransaction().loadObject(EntityProjection.builder().type(Company.class).build(), + "1", scope); + assertEquals("Updated Company Description", company.getDescription()); + return null; + }); + + // Remove + result = doInTransaction(scope -> { + String operationsDoc = """ + { + "atomic:operations": [{ + "op": "remove", + "ref": { + "type": "company", + "id": "1" + } + }] + }"""; + return JsonApiAtomicOperations + .processAtomicOperations(this.dataStore, null, operationsDoc, scope); + }).get(); + + assertEquals(200, result.getKey()); + data = result.getValue().get("atomic:results").get(0).get("data"); + assertTrue(data.isNull()); + + + doInTransaction(scope -> { + Company company = scope.getTransaction().loadObject(EntityProjection.builder().type(Company.class).build(), + "1", scope); + assertNull(company); + return null; + }); + } + + @Test + void addUpdateRemoveHref() throws IOException { + // Add + Pair result = doInTransaction(scope -> { + String operationsDoc = """ + { + "atomic:operations": [{ + "op": "add", + "href": "company", + "data": { + "type": "company", + "id": "1", + "attributes": { + "description": "Company Description" + } + } + }] + }"""; + return JsonApiAtomicOperations + .processAtomicOperations(this.dataStore, null, operationsDoc, scope); + }).get(); + + assertEquals(200, result.getKey()); + JsonNode data = result.getValue().get("atomic:results").get(0).get("data"); + assertEquals("company", data.get("type").asText()); + assertEquals("1", data.get("id").asText()); + + + doInTransaction(scope -> { + Company company = scope.getTransaction().loadObject(EntityProjection.builder().type(Company.class).build(), + "1", scope); + assertEquals("Company Description", company.getDescription()); + return null; + }); + + // Update + result = doInTransaction(scope -> { + String operationsDoc = """ + { + "atomic:operations": [{ + "op": "update", + "href": "company/1", + "data": { + "type": "company", + "id": "1", + "attributes": { + "description": "Updated Company Description" + } + } + }] + }"""; + return JsonApiAtomicOperations + .processAtomicOperations(this.dataStore, null, operationsDoc, scope); + }).get(); + + assertEquals(200, result.getKey()); + data = result.getValue().get("atomic:results").get(0).get("data"); + assertTrue(data.isNull()); + + doInTransaction(scope -> { + Company company = scope.getTransaction().loadObject(EntityProjection.builder().type(Company.class).build(), + "1", scope); + assertEquals("Updated Company Description", company.getDescription()); + return null; + }); + + // Remove + result = doInTransaction(scope -> { + String operationsDoc = """ + { + "atomic:operations": [{ + "op": "remove", + "href" : "company/1" + }] + }"""; + return JsonApiAtomicOperations + .processAtomicOperations(this.dataStore, null, operationsDoc, scope); + }).get(); + + assertEquals(200, result.getKey()); + data = result.getValue().get("atomic:results").get(0).get("data"); + assertTrue(data.isNull()); + + + doInTransaction(scope -> { + Company company = scope.getTransaction().loadObject(EntityProjection.builder().type(Company.class).build(), + "1", scope); + assertNull(company); + return null; + }); + + } + + @Test + void addRemoveLid() throws IOException { + // Add + Pair result = doInTransaction(scope -> { + String operationsDoc = """ + { + "atomic:operations": [{ + "op": "add", + "href": "person", + "data": { + "lid": "24fd9ef5-41dc-49b6-984e-ab958bb328c0", + "type": "person", + "attributes": { + "firstName": "John", + "lastName": "Doe" + }, + "relationships": { + "bestFriend": { + "data": { + "lid": "24fd9ef5-41dc-49b6-984e-ab958bb328c0", + "type": "person" + } + } + } + } + },{ + "op": "add", + "data": { + "lid": "386f2e88-26a7-4202-a238-06692df77c28", + "type": "person", + "attributes": { + "firstName": "Jane", + "lastName": "Doe" + } + } + },{ + "op": "update", + "ref": { + "type": "person", + "lid": "386f2e88-26a7-4202-a238-06692df77c28", + "relationship": "bestFriend" + }, + "data": { + "lid": "24fd9ef5-41dc-49b6-984e-ab958bb328c0", + "type": "person" + } + },{ + "op": "update", + "data": { + "lid": "386f2e88-26a7-4202-a238-06692df77c28", + "type": "person", + "attributes": { + "firstName": "Mary" + } + } + }] + }"""; + return JsonApiAtomicOperations + .processAtomicOperations(this.dataStore, null, operationsDoc, scope); + }).get(); + + assertEquals(200, result.getKey()); + + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode data = result.getValue(); + Results results = objectMapper.treeToValue(data, Results.class); + assertEquals(4, results.getResults().size()); + Resource person1 = results.getResults().get(0).getData(); + Resource person2 = results.getResults().get(1).getData(); + assertEquals("1", person1.getId()); + assertEquals("2", person2.getId()); + + String expected = """ + {"atomic:results":[{"data":{"type":"person","id":"1","attributes":{"firstName":"John","lastName":"Doe"},"relationships":{"bestFriend":{"data":{"type":"person","id":"1"}}}}},{"data":{"type":"person","id":"2","attributes":{"firstName":"Mary","lastName":"Doe"},"relationships":{"bestFriend":{"data":{"type":"person","id":"1"}}}}},{"data":null},{"data":null}]}"""; + String actual = data.toString(); + assertEquals(expected, actual); + + // Remove + result = doInTransaction(scope -> { + String operationsDoc = """ + { + "atomic:operations": [{ + "op": "remove", + "href": "/person", + "data": { + "type": "person", + "id": "1" + } + },{ + "op": "remove", + "ref": { + "type": "person", + "id": "2" + } + }] + }"""; + return JsonApiAtomicOperations + .processAtomicOperations(this.dataStore, null, operationsDoc, scope); + }).get(); + + assertEquals(200, result.getKey()); + expected = """ + {"atomic:results":[{"data":null},{"data":null}]}"""; + actual = result.getValue().toString(); + assertEquals(expected, actual); + } + + @Test + void nullHeader() { + assertFalse(JsonApiAtomicOperations.isAtomicOperationsExtension(null)); + } + + @Test + void jsonPatchHeader() { + assertFalse(JsonApiAtomicOperations.isAtomicOperationsExtension("application/vnd.api+json; ext=jsonpatch")); + } + + @Test + void atomicOperationsHeader() { + assertTrue(JsonApiAtomicOperations + .isAtomicOperationsExtension("application/vnd.api+json;ext=\"https://jsonapi.org/ext/atomic\"")); + } + + @Test + void atomicOperationsHeaderNoQuotes() { + assertTrue(JsonApiAtomicOperations + .isAtomicOperationsExtension("application/vnd.api+json;ext=https://jsonapi.org/ext/atomic")); + } + + @Test + void atomicOperationsHeaderNoQuotesSpaces() { + assertTrue(JsonApiAtomicOperations + .isAtomicOperationsExtension("application/vnd.api+json; ext = https://jsonapi.org/ext/atomic")); + } + + @Test + void atomicOperationsHeaderNoValue() { + assertFalse(JsonApiAtomicOperations + .isAtomicOperationsExtension("application/vnd.api+json;ext=")); + } + + @Test + void atomicOperationsHeaderSingleQuote() { + assertFalse(JsonApiAtomicOperations + .isAtomicOperationsExtension("application/vnd.api+json;ext=\"")); + } + + @Test + void atomicOperationsHeaderMultiple() { + assertTrue(JsonApiAtomicOperations + .isAtomicOperationsExtension( + "application/vnd.api+json;ext=\"jsonpatch https://jsonapi.org/ext/atomic\"")); + } +} diff --git a/elide-core/src/test/java/com/yahoo/elide/jsonapi/models/OperationTest.java b/elide-core/src/test/java/com/yahoo/elide/jsonapi/models/OperationTest.java new file mode 100644 index 0000000000..1fb7669966 --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/jsonapi/models/OperationTest.java @@ -0,0 +1,50 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.jsonapi.models; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +/** + * Tests for Operation. + */ +class OperationTest { + + @Test + void write() throws JsonProcessingException { + ObjectMapper objectMapper = new ObjectMapper(); + Ref ref = new Ref("articles", "13", null, null); + Operation operation = new Operation(Operation.OperationCode.ADD, ref, null, null, null); + String json = objectMapper.writeValueAsString(operation); + String expected = """ + {"op":"add","ref":{"type":"articles","id":"13"}}"""; + assertEquals(expected, json); + } + + @Test + void readSingle() throws JsonProcessingException { + String json = """ + { + "op": "update", + "data": { + "type": "articles", + "id": "13", + "attributes": { + "title": "To TDD or Not" + } + } + } + """; + ObjectMapper objectMapper = new ObjectMapper(); + Operation operation = objectMapper.readValue(json, Operation.class); + assertEquals(Operation.OperationCode.UPDATE, operation.getOperationCode()); + Resource resource = objectMapper.treeToValue(operation.getData(), Resource.class); + assertEquals("articles", resource.getType()); + } +} diff --git a/elide-core/src/test/java/com/yahoo/elide/jsonapi/models/OperationsTest.java b/elide-core/src/test/java/com/yahoo/elide/jsonapi/models/OperationsTest.java new file mode 100644 index 0000000000..b6b12a920b --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/jsonapi/models/OperationsTest.java @@ -0,0 +1,45 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.jsonapi.models; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.junit.jupiter.api.Test; + +/** + * Tests for Operations. + */ +class OperationsTest { + + @Test + void read() throws JsonProcessingException { + String json = """ + { + "atomic:operations": [{ + "op": "remove", + "ref": { + "type": "articles", + "id": "1", + "relationship": "comments" + }, + "data": [ + { "type": "comments", "id": "12" }, + { "type": "comments", "id": "13" } + ] + }] + } + """; + ObjectMapper objectMapper = new ObjectMapper(); + Operations operations = objectMapper.readValue(json, Operations.class); + assertEquals(1, operations.getOperations().size()); + Operation operation = operations.getOperations().get(0); + assertEquals(Operation.OperationCode.REMOVE, operation.getOperationCode()); + assertEquals("articles", operation.getRef().getType()); + } +} diff --git a/elide-core/src/test/java/example/Person.java b/elide-core/src/test/java/example/Person.java new file mode 100644 index 0000000000..f697fee39b --- /dev/null +++ b/elide-core/src/test/java/example/Person.java @@ -0,0 +1,34 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package example; + +import com.yahoo.elide.annotation.Include; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.Data; + +/** + * Model for person. + */ +@Entity +@Table(name = "person") +@Include(name = "person", description = "A Person") +@Data +public class Person { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private long id; + private String firstName; + private String lastName; + + @OneToOne + private Person bestFriend; +} diff --git a/elide-integration-tests/src/test/java/com/yahoo/elide/tests/ResourceIT.java b/elide-integration-tests/src/test/java/com/yahoo/elide/tests/ResourceIT.java index 1e7ae3efc1..a9631d92ce 100644 --- a/elide-integration-tests/src/test/java/com/yahoo/elide/tests/ResourceIT.java +++ b/elide-integration-tests/src/test/java/com/yahoo/elide/tests/ResourceIT.java @@ -49,6 +49,7 @@ import com.yahoo.elide.core.request.EntityProjection; import com.yahoo.elide.core.utils.JsonParser; import com.yahoo.elide.initialization.IntegrationTest; +import com.yahoo.elide.jsonapi.JsonApi; import com.yahoo.elide.jsonapi.models.JsonApiDocument; import com.yahoo.elide.test.jsonapi.elements.Data; import com.yahoo.elide.test.jsonapi.elements.Resource; @@ -2996,4 +2997,437 @@ public void testMissingVersionedModels() throws Exception { .then() .statusCode(HttpStatus.SC_NOT_FOUND); } + + @Test + public void atomicOpCreateDependent() { + String request = jsonParser.getJson("/ResourceIT/atomicOpCreateDependent.req.json"); + String expected = jsonParser.getJson("/ResourceIT/atomicOpCreateDependent.json"); + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body(request) + .post("/operations") + .then() + .statusCode(HttpStatus.SC_OK) + .body(equalTo(expected)); + } + + @Test + public void atomicOpCreateChildRelateExisting() { + String request = jsonParser.getJson("/ResourceIT/atomicOpCreateChildRelateExisting.req.json"); + String expected = jsonParser.getJson("/ResourceIT/atomicOpCreateChildRelateExisting.json"); + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body(request) + .post("/operations") + .then() + .statusCode(HttpStatus.SC_OK) + .body(equalTo(expected)); + } + + @Test + public void atomicOpInvalidOp() { + String request = jsonParser.getJson("/ResourceIT/atomicOpNullOp.req.json"); + + String detail = "Bad Request Body'Invalid Atomic Operations extension operation code:null'"; + + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body(request) + .post("/operations") + .then() + .statusCode(HttpStatus.SC_BAD_REQUEST) + .body("errors[0].detail", containsString(Encode.forHtml(detail))); + } + + @Test + public void atomicOpInvalidMissingId() { + String request = jsonParser.getJson("/ResourceIT/atomicOpInvalidMissingId.req.json"); + + String detail = "Bad Request Body'Atomic Operations extension requires all objects to have an assigned " + + "ID (temporary or permanent) when assigning relationships.'"; + + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body(request) + .post("/operations") + .then() + .statusCode(HttpStatus.SC_BAD_REQUEST) + .body("errors[0].detail[0]", equalTo(Encode.forHtml(detail))); + } + + @Test + public void atomicOpInvalidMissingPath() { + String request = jsonParser.getJson("/ResourceIT/atomicOpInvalidMissingPath.req.json"); + + String detail = "Bad Request Body'Atomic Operations extension operation requires either ref or href members to be specified.'"; + + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body(request) + .post("/operations") + .then() + .statusCode(HttpStatus.SC_BAD_REQUEST) + .body("errors[0].detail[0]", equalTo(Encode.forHtml(detail))); + } + + @Test + public void atomicOpUpdateChildRelationToExisting() { + String request = jsonParser.getJson("/ResourceIT/atomicOpUpdateChildRelationToExisting.req.json"); + String expected1 = jsonParser.getJson("/ResourceIT/atomicOpUpdateChildRelationToExisting.1.json"); + String expected2 = jsonParser.getJson("/ResourceIT/atomicOpUpdateChildRelationToExisting.2.json"); + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body(request) + .post("/operations") + .then() + .statusCode(HttpStatus.SC_OK) + .body(equalTo(expected1)); + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .get("/parent/4/children/1") + .then() + .statusCode(HttpStatus.SC_OK) + .body(equalTo(expected2)); + } + + @Test + public void atomicOpReplaceAttributesAndRelationship() { + String request = jsonParser.getJson("/ResourceIT/atomicOpReplaceAttributesAndRelationship.req.json"); + String expected1 = jsonParser.getJson("/ResourceIT/atomicOpReplaceAttributesAndRelationship.json"); + String expected2 = jsonParser.getJson("/ResourceIT/atomicOpReplaceAttributesAndRelationship.2.json"); + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body(request) + .post("/operations") + .then() + .statusCode(HttpStatus.SC_OK) + .body(equalTo(expected1)); + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .get("/parent/1") + .then() + .statusCode(HttpStatus.SC_OK) + .body(jsonEquals(expected2, false)); + } + + @Test + public void atomicOpRemoveObject() { + String req1 = jsonParser.getJson("/ResourceIT/atomicOpRemoveObject.1.req.json"); + String req2 = jsonParser.getJson("/ResourceIT/atomicOpRemoveObject.2.req.json"); + String expectedDirect = jsonParser.getJson("/ResourceIT/atomicOpRemoveObject.direct.json"); + String expected1 = jsonParser.getJson("/ResourceIT/atomicOpRemoveObject.1.json"); + String expected2 = jsonParser.getJson("/ResourceIT/atomicOpRemoveObject.2.json"); + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body(req1) + .post("/operations") + .then() + .statusCode(HttpStatus.SC_OK) + .body(equalTo(expected1)); + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .get("/parent/5") + .then() + .statusCode(HttpStatus.SC_OK) + .body(equalTo(expectedDirect)); + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body(req2) + .post("/operations") + .then() + .statusCode(HttpStatus.SC_OK) + .body(equalTo(expected2)); + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .get("/parent/5") + .then() + .statusCode(HttpStatus.SC_NOT_FOUND); + } + + @Test + public void atomicOpCreateAndRemoveParent() { + String request = jsonParser.getJson("/ResourceIT/atomicOpCreateAndRemoveParent.req.json"); + String expected = jsonParser.getJson("/ResourceIT/atomicOpCreateAndRemoveParent.json"); + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .get("/parent/4") + .then() + .statusCode(HttpStatus.SC_OK); + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body(request) + .post("/operations") + .then() + .statusCode(HttpStatus.SC_OK) + .body(equalTo(expected)); + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .get("/parent/4") + .then() + .statusCode(HttpStatus.SC_NOT_FOUND); + } + + @Test + public void atomicOpAddRoot() { + String request = jsonParser.getJson("/ResourceIT/atomicOpTestAddRoot.req.json"); + String expected1 = jsonParser.getJson("/ResourceIT/atomicOpTestAddRoot.1.json"); + String expected2 = jsonParser.getJson("/ResourceIT/atomicOpTestAddRoot.2.json"); + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body(request) + .post("/operations") + .then() + .statusCode(HttpStatus.SC_OK) + .body(equalTo(expected1)); + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .get("/parent/5") + .then() + .statusCode(HttpStatus.SC_OK) + .body(equalTo(expected2)); + } + + @Test + public void atomicOpUpdateRelationshipDirect() { + String request = jsonParser.getJson("/ResourceIT/atomicOpUpdateRelationshipDirect.req.json"); + String expected1 = jsonParser.getJson("/ResourceIT/atomicOpUpdateRelationshipDirect.1.json"); + String expected2 = jsonParser.getJson("/ResourceIT/atomicOpUpdateRelationshipDirect.2.json"); + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body(request) + .post("/operations") + .then() + .statusCode(HttpStatus.SC_OK) + .body(equalTo(expected1)); + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .get("/parent/1") + .then() + .statusCode(HttpStatus.SC_OK) + .body(jsonEquals(expected2, true)); + } + + @Test + public void atomicOpRemoveSingleRelationship() { + String request = jsonParser.getJson("/ResourceIT/atomicOpRemoveSingleRelationship.req.json"); + String expected1 = jsonParser.getJson("/ResourceIT/atomicOpRemoveSingleRelationship.1.json"); + String expected2 = jsonParser.getJson("/ResourceIT/atomicOpRemoveSingleRelationship.2.json"); + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body(request) + .post("/operations") + .then() + .statusCode(HttpStatus.SC_OK) + .body(equalTo(expected1)); + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .get("/parent/2") + .then() + .statusCode(HttpStatus.SC_OK) + .body(jsonEquals(expected2, false)); + } + + @Test + public void atomicOpAddRelationships() { + String request = jsonParser.getJson("/ResourceIT/atomicOpAddRelationships.req.json"); + String expected1 = jsonParser.getJson("/ResourceIT/atomicOpAddRelationships.json"); + String expected2 = jsonParser.getJson("/ResourceIT/atomicOpAddRelationships.2.json"); + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body(request) + .post("/operations") + .then() + .statusCode(HttpStatus.SC_OK) + .body(equalTo(expected1)); + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .get("/parent/1") + .then() + .statusCode(HttpStatus.SC_OK) + .body(jsonEquals(expected2, false)); + } + + @Test + public void atomicOpCheckWithError() { + String request = jsonParser.getJson("/ResourceIT/atomicOpCheckWithError.req.json"); + String expected = jsonParser.getJson("/ResourceIT/atomicOpCheckWithError.json"); + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body(request) + .post("/operations") + .then() + .statusCode(HttpStatus.SC_BAD_REQUEST) + .body(equalTo(expected)); + } + + @Test + public void atomicOpBadId() { + String request = jsonParser.getJson("/ResourceIT/atomicOpBadId.req.json"); + String expected = jsonParser.getJson("/ResourceIT/atomicOpBadId.json"); + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body(request) + .post("/operations") + .then() + .statusCode(HttpStatus.SC_BAD_REQUEST) + .body(equalTo(expected)); + } + + @Test + public void atomicOpAddUpdate() { + String request = jsonParser.getJson("/ResourceIT/atomicOpAddUpdate.req.json"); + String expected = jsonParser.getJson("/ResourceIT/atomicOpAddUpdate.json"); + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body(request) + .post("/operations") + .then() + .statusCode(HttpStatus.SC_OK) + .body(equalTo(expected)); + } + + @Test + public void atomicOpAddUpdateLid() { + String request = jsonParser.getJson("/ResourceIT/atomicOpAddUpdateLid.req.json"); + String expected = jsonParser.getJson("/ResourceIT/atomicOpAddUpdateLid.json"); + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body(request) + .post("/operations") + .then() + .statusCode(HttpStatus.SC_OK) + .body(equalTo(expected)); + } + + @Test + public void atomicOpBadDelete() { + String request = jsonParser.getJson("/ResourceIT/atomicOpBadDelete.req.json"); + String expected = jsonParser.getJson("/ResourceIT/atomicOpBadDelete.json"); + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body(request) + .post("/operations") + .then() + .statusCode(HttpStatus.SC_BAD_REQUEST) + .body(jsonEquals(expected, false)); + } + + @Test + public void atomicOpIssue608() { + String req = jsonParser.getJson("/ResourceIT/atomicOpIssue608.req.json"); + String expected = jsonParser.getJson("/ResourceIT/atomicOpIssue608.resp.json"); + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body(req) + .post("/operations") + .then() + .statusCode(HttpStatus.SC_OK) + .body(equalTo(expected)); + } + + @Test + public void atomicOpNestedPatch() { + String req = jsonParser.getJson("/ResourceIT/atomicOpNestedPatchCreate.req.json"); + String expected = jsonParser.getJson("/ResourceIT/atomicOpNestedPatchCreate.resp.json"); + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body(req) + .post("/operations") + .then() + .statusCode(HttpStatus.SC_OK) + .body(equalTo(expected)); + } + + @Test + public void atomicOpCreatedRootNoReadPermRequired() { + String req = jsonParser.getJson("/ResourceIT/atomicOpNoReadPermForNew.req.json"); + String badReq = """ + { + "atomic:operations": [ + { + "op": "add", + "href": "/specialread/1/child", + "data": { + "type": "child", + "id": "12345678-1234-1234-1234-123456789ab2" + } + } + ] + }"""; + String expected = jsonParser.getJson("/ResourceIT/atomicOpNoReadPermForNew.resp.json"); + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body(req) + .post("/operations") + .then() + .statusCode(HttpStatus.SC_OK) + .body(equalTo(expected)); + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body(badReq) + .post("/operations") + .then() + .statusCode(HttpStatus.SC_BAD_REQUEST) + .body(equalTo("[{\"errors\":[{\"detail\":\"UpdatePermission Denied\",\"status\":\"403\"}]}]")); + } + + @Test + public void atomicOpNoCommit() { + String req = jsonParser.getJson("/ResourceIT/atomicOpNoCommit.req.json"); + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body(req) + .post("/operations") + .then() + .statusCode(HttpStatus.SC_FORBIDDEN) + .body(equalTo("{\"errors\":[{\"detail\":\"CreatePermission Denied\"}]}")); + } + + @Test + public void atomicOpDeferredOnCreate() { + String request = jsonParser.getJson("/ResourceIT/atomicOpDeferredOnCreate.req.json"); + String expected = jsonParser.getJson("/ResourceIT/atomicOpDeferredOnCreate.json"); + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body(request) + .post("/operations") + .then() + .statusCode(HttpStatus.SC_FORBIDDEN) + .body(equalTo(expected)); + } } diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddRelationships.2.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddRelationships.2.json new file mode 100644 index 0000000000..7bd00f3036 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddRelationships.2.json @@ -0,0 +1,30 @@ +{ + "data":{ + "type":"parent", + "id":"1", + "attributes":{ + "firstName":null + }, + "relationships":{ + "children":{ + "data":[ + { + "type":"child", + "id":"4" + }, + { + "type":"child", + "id":"5" + }, + { + "type":"child", + "id":"1" + } + ] + }, + "spouses":{ + "data":[] + } + } + } +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddRelationships.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddRelationships.json new file mode 100644 index 0000000000..a8ef6fb3e3 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddRelationships.json @@ -0,0 +1,7 @@ +{ + "atomic:results": [ + { + "data": null + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddRelationships.req.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddRelationships.req.json new file mode 100644 index 0000000000..e251418e0b --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddRelationships.req.json @@ -0,0 +1,18 @@ +{ + "atomic:operations": [ + { + "op": "add", + "href": "/parent/1/relationships/children", + "data": [ + { + "type": "child", + "id": "5" + }, + { + "type": "child", + "id": "4" + } + ] + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddUpdate.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddUpdate.json new file mode 100644 index 0000000000..f45e4e11e2 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddUpdate.json @@ -0,0 +1,53 @@ +{ + "atomic:results": [ + { + "data": { + "type": "parent", + "id": "5", + "attributes": { + "firstName": null + }, + "relationships": { + "children": { + "data": [] + }, + "spouses": { + "data": [ + { + "type": "parent", + "id": "2" + } + ] + } + } + } + }, + { + "data": { + "type": "child", + "id": "6", + "attributes": { + "name": null + }, + "relationships": { + "friends": { + "data": [ + { + "type": "child", + "id": "1" + } + ] + }, + "parents": { + "data": [ + { + "type": "parent", + "id": "2" + } + ] + } + } + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddUpdate.req.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddUpdate.req.json new file mode 100644 index 0000000000..b29a9e9933 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddUpdate.req.json @@ -0,0 +1,54 @@ +{ + "atomic:operations": [ + { + "op": "add", + "href": "/parent", + "data": { + "type": "parent", + "id": "5d57b6ee-f538-4e49-8f35-edc00f6272d2", + "relationships": { + "spouses": { + "data": { + "type": "parent", + "id": "2" + } + }, + "children": { + "data": [ + { + "type": "child", + "id": "2d1b70cb-5821-4229-ba14-4dd7d3bd9b97" + } + ] + } + } + } + }, + { + "op": "add", + "href": "/parent/5d57b6ee-f538-4e49-8f35-edc00f6272d2/children", + "data": { + "type": "child", + "id": "2d1b70cb-5821-4229-ba14-4dd7d3bd9b97", + "relationships": { + "parents": { + "data": [ + { + "type": "parent", + "id": "2" + } + ] + }, + "friends": { + "data": [ + { + "type": "child", + "id": "1" + } + ] + } + } + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddUpdateLid.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddUpdateLid.json new file mode 100644 index 0000000000..4da900e4e4 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddUpdateLid.json @@ -0,0 +1,53 @@ +{ + "atomic:results": [ + { + "data": { + "type": "parent", + "id": "5", + "attributes": { + "firstName": null + }, + "relationships": { + "children": { + "data": [] + }, + "spouses": { + "data": [ + { + "type": "parent", + "id": "5" + } + ] + } + } + } + }, + { + "data": { + "type": "child", + "id": "6", + "attributes": { + "name": null + }, + "relationships": { + "friends": { + "data": [ + { + "type": "child", + "id": "1" + } + ] + }, + "parents": { + "data": [ + { + "type": "parent", + "id": "2" + } + ] + } + } + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddUpdateLid.req.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddUpdateLid.req.json new file mode 100644 index 0000000000..05ccf07f5a --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpAddUpdateLid.req.json @@ -0,0 +1,53 @@ +{ + "atomic:operations": [ + { + "op": "add", + "data": { + "type": "parent", + "lid": "5d57b6ee-f538-4e49-8f35-edc00f6272d2", + "relationships": { + "spouses": { + "data": { + "type": "parent", + "lid": "5d57b6ee-f538-4e49-8f35-edc00f6272d2" + } + }, + "children": { + "data": [ + { + "type": "child", + "lid": "2d1b70cb-5821-4229-ba14-4dd7d3bd9b97" + } + ] + } + } + } + }, + { + "op": "add", + "href": "/parent/5d57b6ee-f538-4e49-8f35-edc00f6272d2/children", + "data": { + "type": "child", + "lid": "2d1b70cb-5821-4229-ba14-4dd7d3bd9b97", + "relationships": { + "parents": { + "data": [ + { + "type": "parent", + "id": "2" + } + ] + }, + "friends": { + "data": [ + { + "type": "child", + "id": "1" + } + ] + } + } + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpBadDelete.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpBadDelete.json new file mode 100644 index 0000000000..3f7d8ca941 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpBadDelete.json @@ -0,0 +1 @@ +[{"errors":[{"detail":"Unknown identifier 10 for parent","status":"404"}]}] diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpBadDelete.req.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpBadDelete.req.json new file mode 100644 index 0000000000..043f382610 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpBadDelete.req.json @@ -0,0 +1,8 @@ +{ + "atomic:operations": [ + { + "op": "remove", + "href": "/parent/10" + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpBadId.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpBadId.json new file mode 100644 index 0000000000..3001797991 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpBadId.json @@ -0,0 +1,4 @@ +[ + {"errors":[{"detail":"Subsequent operation failed."}]}, + {"errors":[{"detail":"Unknown identifier 99999 for child","status":"404"}]} +] diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpBadId.req.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpBadId.req.json new file mode 100644 index 0000000000..f78ca19fa0 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpBadId.req.json @@ -0,0 +1,58 @@ +{ + "atomic:operations": [ + { + "op": "add", + "href": "/parent", + "data": { + "type": "parent", + "id": "5d57b6ee-f538-4e49-8f35-edc00f6272d2", + "attributes": {}, + "relationships": { + "spouses": { + "data": { + "type": "parent", + "id": "2" + } + }, + "children": { + "data": [ + { + "type": "child", + "id": "2d1b70cb-5821-4229-ba14-4dd7d3bd9b97" + } + ] + } + } + } + }, + { + "op": "add", + "href": "/parent/5d57b6ee-f538-4e49-8f35-edc00f6272d2/children", + "data": { + "type": "child", + "id": "2d1b70cb-5821-4229-ba14-4dd7d3bd9b97", + "attributes": { + "name": "new child" + }, + "relationships": { + "parents": { + "data": [ + { + "type": "parent", + "id": "2" + } + ] + }, + "friends": { + "data": [ + { + "type": "child", + "id": "99999" + } + ] + } + } + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpCheckWithError.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpCheckWithError.json new file mode 100644 index 0000000000..639d66be7e --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpCheckWithError.json @@ -0,0 +1,17 @@ +[ + { + "errors": [ + { + "detail": "Subsequent operation failed." + } + ] + }, + { + "errors": [ + { + "detail":"Unknown attribute badAttr in parent", + "status":"404" + } + ] + } +] diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpCheckWithError.req.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpCheckWithError.req.json new file mode 100644 index 0000000000..7ddb33cd0a --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpCheckWithError.req.json @@ -0,0 +1,23 @@ +{ + "atomic:operations": [ + { + "op": "add", + "href": "/parent", + "data": { + "type": "parent", + "id": "woot" + } + }, + { + "op": "add", + "href": "/parent", + "data": { + "type": "parent", + "id": "nice", + "attributes": { + "badAttr": "no val" + } + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpCreateAndRemoveParent.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpCreateAndRemoveParent.json new file mode 100644 index 0000000000..2dd6576ed1 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpCreateAndRemoveParent.json @@ -0,0 +1,24 @@ +{ + "atomic:results": [ + { + "data": { + "type": "parent", + "id": "5", + "attributes": { + "firstName": "ron jon" + }, + "relationships": { + "children": { + "data": [] + }, + "spouses": { + "data": [] + } + } + } + }, + { + "data": null + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpCreateAndRemoveParent.req.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpCreateAndRemoveParent.req.json new file mode 100644 index 0000000000..9ece7902e1 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpCreateAndRemoveParent.req.json @@ -0,0 +1,23 @@ +{ + "atomic:operations": [ + { + "op": "add", + "href": "/parent", + "data": { + "type": "parent", + "id": "12345678-1234-1234-1234-123456789ab1", + "attributes": { + "firstName": "ron jon" + } + } + }, + { + "op": "remove", + "href": "/parent", + "data": { + "type": "parent", + "id": "4" + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpCreateChildRelateExisting.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpCreateChildRelateExisting.json new file mode 100644 index 0000000000..c817ab6c6b --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpCreateChildRelateExisting.json @@ -0,0 +1,26 @@ +{ + "atomic:results": [ + { + "data": { + "type": "child", + "id": "6", + "attributes": { + "name": null + }, + "relationships": { + "friends": { + "data": [] + }, + "parents": { + "data": [ + { + "type": "parent", + "id": "4" + } + ] + } + } + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpCreateChildRelateExisting.req.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpCreateChildRelateExisting.req.json new file mode 100644 index 0000000000..114a5afee6 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpCreateChildRelateExisting.req.json @@ -0,0 +1,22 @@ +{ + "atomic:operations": [ + { + "op": "add", + "href": "/parent/4/children", + "data": { + "type": "child", + "id": "12345678-1234-1234-1234-123456789ab1", + "relationships": { + "parents": { + "data": [ + { + "type": "parent", + "id": "4" + } + ] + } + } + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpCreateDependent.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpCreateDependent.json new file mode 100644 index 0000000000..be5fcbf05a --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpCreateDependent.json @@ -0,0 +1,48 @@ +{ + "atomic:results": [ + { + "data": { + "type": "parent", + "id": "5", + "attributes": { + "firstName": "test parent" + }, + "relationships": { + "children": { + "data": [ + { + "type": "child", + "id": "6" + } + ] + }, + "spouses": { + "data": [] + } + } + } + }, + { + "data": { + "type": "child", + "id": "6", + "attributes": { + "name": null + }, + "relationships": { + "friends": { + "data": [] + }, + "parents": { + "data": [ + { + "type": "parent", + "id": "5" + } + ] + } + } + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpCreateDependent.req.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpCreateDependent.req.json new file mode 100644 index 0000000000..824977eb22 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpCreateDependent.req.json @@ -0,0 +1,33 @@ +{ + "atomic:operations": [ + { + "op": "add", + "href": "/parent", + "data": { + "type": "parent", + "id": "12345678-1234-1234-1234-123456789ab1", + "attributes": { + "firstName": "test parent" + }, + "relationships": { + "children": { + "data": [ + { + "type": "child", + "id": "12345678-1234-1234-1234-123456789ab2" + } + ] + } + } + } + }, + { + "op": "add", + "href": "/parent/12345678-1234-1234-1234-123456789ab1/children", + "data": { + "type": "child", + "id": "12345678-1234-1234-1234-123456789ab2" + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpDeferredOnCreate.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpDeferredOnCreate.json new file mode 100644 index 0000000000..89535edf76 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpDeferredOnCreate.json @@ -0,0 +1,7 @@ +{ + "errors": [ + { + "detail" : "ReadPermission Denied" + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpDeferredOnCreate.req.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpDeferredOnCreate.req.json new file mode 100644 index 0000000000..938d21e4e9 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpDeferredOnCreate.req.json @@ -0,0 +1,28 @@ +{ + "atomic:operations": [ + { + "op": "add", + "href": "/createButNoRead", + "data": { + "type": "createButNoRead", + "id": "gh202bcd3-8103-4166-a5b0-abccaa07322d" + } + }, + { + "op": "add", + "href": "/createButNoRead/gh202bcd3-8103-4166-a5b0-abccaa07322d/otherObjects", + "data": { + "type": "createButNoReadChild", + "id": "r7712424-b52a-44c2-b0fd-e654a38bbf8a", + "relationships": { + "otherObject": { + "data": { + "type": "createButNoRead", + "id": "gh202bcd3-8103-4166-a5b0-abccaa07322d" + } + } + } + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpInvalidMissingId.req.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpInvalidMissingId.req.json new file mode 100644 index 0000000000..e6e4f45a80 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpInvalidMissingId.req.json @@ -0,0 +1,29 @@ +{ + "atomic:operations": [ + { + "op": "add", + "href": "/book", + "data": { + "type": "book", + "relationships": { + "authors": { + "data": [ + { + "type": "author", + "id": "authorId" + } + ] + } + } + } + }, + { + "op": "add", + "href": "/author", + "data": { + "id": "authorId", + "type": "author" + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpInvalidMissingPath.req.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpInvalidMissingPath.req.json new file mode 100644 index 0000000000..a893a2a7a0 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpInvalidMissingPath.req.json @@ -0,0 +1,29 @@ +{ + "atomic:operations": [ + { + "op": "remove", + "data": { + "type": "book", + "id": "bookId", + "relationships": { + "authors": { + "data": [ + { + "type": "author", + "id": "authorId" + } + ] + } + } + } + }, + { + "op": "add", + "href": "/author", + "data": { + "id": "authorId", + "type": "author" + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpIssue608.req.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpIssue608.req.json new file mode 100644 index 0000000000..83c0ccdf85 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpIssue608.req.json @@ -0,0 +1,44 @@ +{ + "atomic:operations": [ + { + "op": "add", + "href": "/parent", + "data": { + "type": "parent", + "id": "12345678-1234-1234-1234-123456789ab1", + "attributes": { + "firstName": "Parent1" + } + } + }, + { + "op": "update", + "href": "/parent/12345678-1234-1234-1234-123456789ab1", + "data": { + "type": "parent", + "id": "12345678-1234-1234-1234-123456789ab1", + "attributes": { + "firstName": "Corrected" + }, + "relationships": { + "children": { + "data": [ + { + "type": "child", + "id": "12345678-1234-1234-1234-123456789ab2" + } + ] + } + } + } + }, + { + "op": "add", + "href": "/parent/12345678-1234-1234-1234-123456789ab1/children", + "data": { + "type": "child", + "id": "12345678-1234-1234-1234-123456789ab2" + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpIssue608.resp.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpIssue608.resp.json new file mode 100644 index 0000000000..ed7f9ed661 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpIssue608.resp.json @@ -0,0 +1,53 @@ +{ + "atomic:results": [ + { + "data": { + "type": "parent", + "id": "5", + "attributes": { + "firstName": "Corrected" + }, + "relationships": { + "children": { + "data": [ + { + "type": "child", + "id": "6" + } + ] + }, + "spouses": { + "data": [ + ] + } + } + } + }, + { + "data": null + }, + { + "data": { + "type": "child", + "id": "6", + "attributes": { + "name": null + }, + "relationships": { + "friends": { + "data": [ + ] + }, + "parents": { + "data": [ + { + "type": "parent", + "id": "5" + } + ] + } + } + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpNestedPatchCreate.req.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpNestedPatchCreate.req.json new file mode 100644 index 0000000000..0283fd6fa5 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpNestedPatchCreate.req.json @@ -0,0 +1,45 @@ +{ + "atomic:operations": [ + { + "op": "add", + "href": "/parent", + "data": { + "type": "parent", + "id": "12345678-1234-1234-1234-123456789ab1", + "attributes": { + "firstName": "Parent1" + }, + "relationships": { + "children": { + "data": [ + { + "type": "child", + "id": "12345678-1234-1234-1234-123456789ab2" + } + ] + } + } + } + }, + { + "op": "add", + "href": "/parent/12345678-1234-1234-1234-123456789ab1/children", + "data": { + "type": "child", + "id": "12345678-1234-1234-1234-123456789ab2" + } + }, + { + "op": "add", + "href": "/parent/12345678-1234-1234-1234-123456789ab1/children/12345678-1234-1234-1234-123456789ab2/parents", + "data": { + "type": "parent", + "id": "12345678-1234-1234-1234-123456789ab3", + "attributes": { + "firstName": "Parent2", + "specialAttribute": "this should succeed!" + } + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpNestedPatchCreate.resp.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpNestedPatchCreate.resp.json new file mode 100644 index 0000000000..0f9ce4a256 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpNestedPatchCreate.resp.json @@ -0,0 +1,77 @@ +{ + "atomic:results": [ + { + "data": { + "type": "parent", + "id": "5", + "attributes": { + "firstName": "Parent1" + }, + "relationships": { + "children": { + "data": [ + { + "type": "child", + "id": "6" + } + ] + }, + "spouses": { + "data": [ + ] + } + } + } + }, + { + "data": { + "type": "child", + "id": "6", + "attributes": { + "name": null + }, + "relationships": { + "friends": { + "data": [ + ] + }, + "parents": { + "data": [ + { + "type": "parent", + "id": "5" + }, + { + "type": "parent", + "id": "6" + } + ] + } + } + } + }, + { + "data": { + "type": "parent", + "id": "6", + "attributes": { + "firstName": "Parent2" + }, + "relationships": { + "children": { + "data": [ + { + "type": "child", + "id": "6" + } + ] + }, + "spouses": { + "data": [ + ] + } + } + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpNoCommit.req.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpNoCommit.req.json new file mode 100644 index 0000000000..7b78cd1f87 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpNoCommit.req.json @@ -0,0 +1,15 @@ +{ + "atomic:operations": [ + { + "op": "add", + "href": "/nocommit", + "data": { + "type": "nocommit", + "id": "12345678-1234-1234-1234-123456789ab1", + "attributes": { + "value": "value" + } + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpNoReadPermForNew.req.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpNoReadPermForNew.req.json new file mode 100644 index 0000000000..3018935470 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpNoReadPermForNew.req.json @@ -0,0 +1,31 @@ +{ + "atomic:operations": [ + { + "op": "add", + "href": "/specialread", + "data": { + "type": "specialread", + "id": "12345678-1234-1234-1234-123456789ab1", + "attributes": { + "value": "special value" + }, + "relationships": { + "child": { + "data": { + "type": "child", + "id": "12345678-1234-1234-1234-123456789ab2" + } + } + } + } + }, + { + "op": "add", + "href": "/specialread/12345678-1234-1234-1234-123456789ab1/child", + "data": { + "type": "child", + "id": "12345678-1234-1234-1234-123456789ab2" + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpNoReadPermForNew.resp.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpNoReadPermForNew.resp.json new file mode 100644 index 0000000000..6271cd7f05 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpNoReadPermForNew.resp.json @@ -0,0 +1,40 @@ +{ + "atomic:results": [ + { + "data": { + "type": "specialread", + "id": "1", + "attributes": { + "value": "special value" + }, + "relationships": { + "child": { + "data": { + "type": "child", + "id": "6" + } + } + } + } + }, + { + "data": { + "type": "child", + "id": "6", + "attributes": { + "name": null + }, + "relationships": { + "friends": { + "data": [ + ] + }, + "parents": { + "data": [ + ] + } + } + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpNullOp.req.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpNullOp.req.json new file mode 100644 index 0000000000..bcf20c9d0d --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpNullOp.req.json @@ -0,0 +1,15 @@ +{ + "atomic:operations": [ + { + "op": "null", + "href": "/book", + "data": { + "type": "book", + "id": "123", + "attributes": { + "title": "My New Book" + } + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveObject.1.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveObject.1.json new file mode 100644 index 0000000000..e0eabb4214 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveObject.1.json @@ -0,0 +1,21 @@ +{ + "atomic:results": [ + { + "data": { + "type": "parent", + "id": "5", + "attributes": { + "firstName": "Im going to be immediately deleted" + }, + "relationships": { + "children": { + "data": [] + }, + "spouses": { + "data": [] + } + } + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveObject.1.req.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveObject.1.req.json new file mode 100644 index 0000000000..8e8eed7e48 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveObject.1.req.json @@ -0,0 +1,15 @@ +{ + "atomic:operations": [ + { + "op": "add", + "href": "/parent", + "data": { + "type": "parent", + "id": "12345678-1234-1234-1234-123456789ab1", + "attributes": { + "firstName": "Im going to be immediately deleted" + } + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveObject.2.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveObject.2.json new file mode 100644 index 0000000000..a8ef6fb3e3 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveObject.2.json @@ -0,0 +1,7 @@ +{ + "atomic:results": [ + { + "data": null + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveObject.2.req.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveObject.2.req.json new file mode 100644 index 0000000000..6052c17491 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveObject.2.req.json @@ -0,0 +1,12 @@ +{ + "atomic:operations": [ + { + "op": "remove", + "href": "/parent", + "data": { + "type": "parent", + "id": "5" + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveObject.direct.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveObject.direct.json new file mode 100644 index 0000000000..3edba7190f --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveObject.direct.json @@ -0,0 +1,17 @@ +{ + "data":{ + "type":"parent", + "id":"5", + "attributes":{ + "firstName":"Im going to be immediately deleted" + }, + "relationships":{ + "children":{ + "data":[] + }, + "spouses":{ + "data":[] + } + } + } +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveSingleRelationship.1.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveSingleRelationship.1.json new file mode 100644 index 0000000000..a8ef6fb3e3 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveSingleRelationship.1.json @@ -0,0 +1,7 @@ +{ + "atomic:results": [ + { + "data": null + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveSingleRelationship.2.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveSingleRelationship.2.json new file mode 100644 index 0000000000..86e89968ea --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveSingleRelationship.2.json @@ -0,0 +1,22 @@ +{ + "data":{ + "type":"parent", + "id":"2", + "attributes":{ + "firstName":"John" + }, + "relationships":{ + "spouses":{ + "data":[] + }, + "children":{ + "data":[ + { + "type":"child", + "id":"3" + } + ] + } + } + } +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveSingleRelationship.req.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveSingleRelationship.req.json new file mode 100644 index 0000000000..6760c9381f --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpRemoveSingleRelationship.req.json @@ -0,0 +1,14 @@ +{ + "atomic:operations": [ + { + "op": "remove", + "href": "/parent/2/relationships/children", + "data": [ + { + "type": "child", + "id": "2" + } + ] + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpReplaceAttributesAndRelationship.2.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpReplaceAttributesAndRelationship.2.json new file mode 100644 index 0000000000..edd32b6653 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpReplaceAttributesAndRelationship.2.json @@ -0,0 +1,17 @@ +{ + "data":{ + "type":"parent", + "id":"1", + "attributes":{ + "firstName":"I've been modified =-o" + }, + "relationships":{ + "spouses":{ + "data":[] + }, + "children":{ + "data":[] + } + } + } +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpReplaceAttributesAndRelationship.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpReplaceAttributesAndRelationship.json new file mode 100644 index 0000000000..a8ef6fb3e3 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpReplaceAttributesAndRelationship.json @@ -0,0 +1,7 @@ +{ + "atomic:results": [ + { + "data": null + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpReplaceAttributesAndRelationship.req.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpReplaceAttributesAndRelationship.req.json new file mode 100644 index 0000000000..e8c09e7516 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpReplaceAttributesAndRelationship.req.json @@ -0,0 +1,20 @@ +{ + "atomic:operations": [ + { + "op": "update", + "href": "/parent/1", + "data": { + "id": "1", + "type": "parent", + "attributes": { + "firstName": "I've been modified =-o" + }, + "relationships": { + "children": { + "data": [] + } + } + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpTestAddRoot.1.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpTestAddRoot.1.json new file mode 100644 index 0000000000..eb337b7cdf --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpTestAddRoot.1.json @@ -0,0 +1,21 @@ +{ + "atomic:results": [ + { + "data": { + "type": "parent", + "id": "5", + "attributes": { + "firstName": "bart" + }, + "relationships": { + "children": { + "data": [] + }, + "spouses": { + "data": [] + } + } + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpTestAddRoot.2.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpTestAddRoot.2.json new file mode 100644 index 0000000000..a22e98dc94 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpTestAddRoot.2.json @@ -0,0 +1,17 @@ +{ + "data":{ + "type":"parent", + "id":"5", + "attributes":{ + "firstName":"bart" + }, + "relationships":{ + "children":{ + "data":[] + }, + "spouses":{ + "data":[] + } + } + } +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpTestAddRoot.req.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpTestAddRoot.req.json new file mode 100644 index 0000000000..0995920e91 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpTestAddRoot.req.json @@ -0,0 +1,15 @@ +{ + "atomic:operations": [ + { + "op": "add", + "href": "/parent", + "data": { + "type": "parent", + "id": "12345678-1234-1234-1234-123456789ab1", + "attributes": { + "firstName": "bart" + } + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpUpdateChildRelationToExisting.1.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpUpdateChildRelationToExisting.1.json new file mode 100644 index 0000000000..a8ef6fb3e3 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpUpdateChildRelationToExisting.1.json @@ -0,0 +1,7 @@ +{ + "atomic:results": [ + { + "data": null + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpUpdateChildRelationToExisting.2.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpUpdateChildRelationToExisting.2.json new file mode 100644 index 0000000000..69f3b918e9 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpUpdateChildRelationToExisting.2.json @@ -0,0 +1,22 @@ +{ + "data":{ + "type":"child", + "id":"1", + "attributes":{ + "name":null + }, + "relationships":{ + "friends":{ + "data":[] + }, + "parents":{ + "data":[ + { + "type":"parent", + "id":"4" + } + ] + } + } + } +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpUpdateChildRelationToExisting.req.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpUpdateChildRelationToExisting.req.json new file mode 100644 index 0000000000..db483d16f8 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpUpdateChildRelationToExisting.req.json @@ -0,0 +1,22 @@ +{ + "atomic:operations": [ + { + "op": "update", + "href": "/parent/1/children/1", + "data": { + "type": "child", + "id": "1", + "relationships": { + "parents": { + "data": [ + { + "type": "parent", + "id": "4" + } + ] + } + } + } + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpUpdateRelationshipDirect.1.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpUpdateRelationshipDirect.1.json new file mode 100644 index 0000000000..a8ef6fb3e3 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpUpdateRelationshipDirect.1.json @@ -0,0 +1,7 @@ +{ + "atomic:results": [ + { + "data": null + } + ] +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpUpdateRelationshipDirect.2.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpUpdateRelationshipDirect.2.json new file mode 100644 index 0000000000..1f5d6f9e26 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpUpdateRelationshipDirect.2.json @@ -0,0 +1,26 @@ +{ + "data":{ + "type":"parent", + "id":"1", + "attributes":{ + "firstName":null + }, + "relationships":{ + "spouses":{ + "data":[] + }, + "children":{ + "data":[ + { + "type":"child", + "id":"1" + }, + { + "type":"child", + "id":"5" + } + ] + } + } + } +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/atomicOpUpdateRelationshipDirect.req.json b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpUpdateRelationshipDirect.req.json new file mode 100644 index 0000000000..ebd853a203 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/atomicOpUpdateRelationshipDirect.req.json @@ -0,0 +1,18 @@ +{ + "atomic:operations": [ + { + "op": "update", + "href": "/parent/1/relationships/children", + "data": [ + { + "type": "child", + "id": "1" + }, + { + "type": "child", + "id": "5" + } + ] + } + ] +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideAutoConfiguration.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideAutoConfiguration.java index 3338389109..85dc7055c4 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideAutoConfiguration.java +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideAutoConfiguration.java @@ -63,10 +63,12 @@ import com.yahoo.elide.spring.controllers.ExportController; import com.yahoo.elide.spring.controllers.GraphqlController; import com.yahoo.elide.spring.controllers.JsonApiController; +import com.yahoo.elide.spring.jackson.ObjectMapperBuilder; import com.yahoo.elide.spring.orm.jpa.EntityManagerProxySupplier; import com.yahoo.elide.spring.orm.jpa.PlatformJpaTransactionSupplier; import com.yahoo.elide.swagger.OpenApiBuilder; import com.yahoo.elide.utils.HeaderUtils; +import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.commons.lang3.StringUtils; import org.springdoc.core.customizers.OpenApiCustomizer; import org.springframework.beans.factory.ObjectProvider; @@ -86,6 +88,7 @@ import org.springframework.context.annotation.Scope; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.support.DefaultTransactionDefinition; @@ -507,8 +510,19 @@ public ErrorMapper getErrorMapper() { @Bean @ConditionalOnMissingBean @Scope(SCOPE_PROTOTYPE) - public JsonApiMapper mapper() { - return new JsonApiMapper(); + public ObjectMapperBuilder objectMapperBuilder( + Optional optionalJackson2ObjectMapperBuilder) { + if (optionalJackson2ObjectMapperBuilder.isPresent()) { + return optionalJackson2ObjectMapperBuilder.get()::build; + } + return ObjectMapper::new; + } + + @Bean + @ConditionalOnMissingBean + @Scope(SCOPE_PROTOTYPE) + public JsonApiMapper jsonApiMapper(ObjectMapperBuilder builder) { + return new JsonApiMapper(builder.build()); } @Bean diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/JsonApiController.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/JsonApiController.java index 3558a79d35..f7c8c33e31 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/JsonApiController.java +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/JsonApiController.java @@ -5,13 +5,11 @@ */ package com.yahoo.elide.spring.controllers; -import static com.yahoo.elide.Elide.JSONAPI_CONTENT_TYPE; -import static com.yahoo.elide.Elide.JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; - import com.yahoo.elide.Elide; import com.yahoo.elide.ElideResponse; import com.yahoo.elide.RefreshableElide; import com.yahoo.elide.core.security.User; +import com.yahoo.elide.jsonapi.JsonApi; import com.yahoo.elide.spring.config.ElideConfigProperties; import com.yahoo.elide.spring.security.AuthenticationUser; import com.yahoo.elide.utils.HeaderUtils; @@ -53,8 +51,10 @@ public class JsonApiController { private final Elide elide; private final ElideConfigProperties settings; private final HeaderUtils.HeaderProcessor headerProcessor; - public static final String JSON_API_CONTENT_TYPE = JSONAPI_CONTENT_TYPE; - public static final String JSON_API_PATCH_CONTENT_TYPE = JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; + + public static final String JSON_API_CONTENT_TYPE = JsonApi.MEDIA_TYPE; + public static final String JSON_API_PATCH_CONTENT_TYPE = JsonApi.JsonPatch.MEDIA_TYPE; + public static final String JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE = JsonApi.AtomicOperations.MEDIA_TYPE; public JsonApiController(RefreshableElide refreshableElide, ElideConfigProperties settings) { log.debug("Started ~~"); @@ -69,7 +69,7 @@ private MultivaluedHashMap convert(MultiValueMap springMVMap) return convertedMap; } - @GetMapping(value = "/**", produces = JSON_API_CONTENT_TYPE) + @GetMapping(value = "/**", produces = JsonApi.MEDIA_TYPE) public Callable> elideGet(@RequestHeader HttpHeaders requestHeaders, @RequestParam MultiValueMap allRequestParams, HttpServletRequest request, Authentication authentication) { @@ -90,7 +90,7 @@ public ResponseEntity call() throws Exception { }; } - @PostMapping(value = "/**", consumes = JSON_API_CONTENT_TYPE, produces = JSON_API_CONTENT_TYPE) + @PostMapping(value = "/**", consumes = JsonApi.MEDIA_TYPE, produces = JsonApi.MEDIA_TYPE) public Callable> elidePost(@RequestHeader HttpHeaders requestHeaders, @RequestParam MultiValueMap allRequestParams, @RequestBody String body, @@ -113,8 +113,8 @@ public ResponseEntity call() throws Exception { @PatchMapping( value = "/**", - consumes = { JSON_API_CONTENT_TYPE, JSON_API_PATCH_CONTENT_TYPE}, - produces = JSON_API_CONTENT_TYPE + consumes = { JsonApi.MEDIA_TYPE, JsonApi.JsonPatch.MEDIA_TYPE }, + produces = JsonApi.MEDIA_TYPE ) public Callable> elidePatch(@RequestHeader HttpHeaders requestHeaders, @RequestParam MultiValueMap allRequestParams, @@ -138,7 +138,7 @@ public ResponseEntity call() throws Exception { }; } - @DeleteMapping(value = "/**", produces = JSON_API_CONTENT_TYPE) + @DeleteMapping(value = "/**", produces = JsonApi.MEDIA_TYPE) public Callable> elideDelete(@RequestHeader HttpHeaders requestHeaders, @RequestParam MultiValueMap allRequestParams, HttpServletRequest request, @@ -160,7 +160,7 @@ public ResponseEntity call() throws Exception { }; } - @DeleteMapping(value = "/**", consumes = JSON_API_CONTENT_TYPE) + @DeleteMapping(value = "/**", consumes = JsonApi.MEDIA_TYPE) public Callable> elideDeleteRelation( @RequestHeader HttpHeaders requestHeaders, @RequestParam MultiValueMap allRequestParams, @@ -184,6 +184,33 @@ public ResponseEntity call() throws Exception { }; } + @PostMapping( + value = "/operations", + consumes = JsonApi.AtomicOperations.MEDIA_TYPE, + produces = JsonApi.AtomicOperations.MEDIA_TYPE + ) + public Callable> elideOperations(@RequestHeader HttpHeaders requestHeaders, + @RequestParam MultiValueMap allRequestParams, + @RequestBody String body, + HttpServletRequest request, Authentication authentication) { + final String apiVersion = HeaderUtils.resolveApiVersion(requestHeaders); + final Map> requestHeadersCleaned = headerProcessor.process(requestHeaders); + final String pathname = getJsonApiPath(request, settings.getJsonApi().getPath()); + final User user = new AuthenticationUser(authentication); + final String baseUrl = getBaseUrlEndpoint(); + + return new Callable>() { + @Override + public ResponseEntity call() throws Exception { + ElideResponse response = elide + .operations(baseUrl, request.getContentType(), request.getContentType(), pathname, body, + convert(allRequestParams), requestHeadersCleaned, user, apiVersion, + UUID.randomUUID()); + return ResponseEntity.status(response.getResponseCode()).body(response.getBody()); + } + }; + } + private String getJsonApiPath(HttpServletRequest request, String prefix) { String pathname = (String) request .getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE); diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/jackson/ObjectMapperBuilder.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/jackson/ObjectMapperBuilder.java new file mode 100644 index 0000000000..27c71eb203 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/jackson/ObjectMapperBuilder.java @@ -0,0 +1,18 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.jackson; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Used to build an ObjectMapper. + * + * @see com.fasterxml.jackson.databind.ObjectMapper + */ +@FunctionalInterface +public interface ObjectMapperBuilder { + ObjectMapper build(); +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/ArtifactMaintainer.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/ArtifactMaintainer.java new file mode 100644 index 0000000000..12eaf9b92e --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/ArtifactMaintainer.java @@ -0,0 +1,35 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package example.models.jpa; + +import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.graphql.subscriptions.annotations.Subscription; +import com.yahoo.elide.graphql.subscriptions.annotations.SubscriptionField; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToMany; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Include(name = "maintainer") +@Entity +@Data +@Subscription +public class ArtifactMaintainer { + @Id + private String name = ""; + + @SubscriptionField + private String commonName = ""; + + private String description = ""; + + @ManyToMany + private List products = new ArrayList<>(); +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/ArtifactProduct.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/ArtifactProduct.java index 2dff08e2f1..011d17ed75 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/ArtifactProduct.java +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/ArtifactProduct.java @@ -9,6 +9,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.Id; +import jakarta.persistence.ManyToMany; import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; @@ -30,4 +31,7 @@ public class ArtifactProduct { @OneToMany(mappedBy = "artifact") private List versions = new ArrayList<>(); + + @ManyToMany + private List maintainers = new ArrayList<>(); } diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/Book.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/Book.java new file mode 100644 index 0000000000..5ce6edf762 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/Book.java @@ -0,0 +1,33 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package example.models.jpa; + +import com.yahoo.elide.annotation.Include; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Data; + +/** + * Model for books. + */ +@Entity +@Table(name = "book") +@Include(name = "book", description = "A Book") +@Data +public class Book { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private long id; + private String title; + + @ManyToOne + private Publisher publisher; +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/Person.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/Person.java new file mode 100644 index 0000000000..347753f842 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/Person.java @@ -0,0 +1,34 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package example.models.jpa; + +import com.yahoo.elide.annotation.Include; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.Data; + +/** + * Model for person. + */ +@Entity +@Table(name = "person") +@Include(name = "person", description = "A Person") +@Data +public class Person { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private long id; + private String firstName; + private String lastName; + + @OneToOne + private Person bestFriend; +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/Publisher.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/Publisher.java new file mode 100644 index 0000000000..3edc692abd --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/Publisher.java @@ -0,0 +1,56 @@ +/* + * Copyright 2017, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package example.models.jpa; + +import com.yahoo.elide.annotation.Include; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; + +import java.util.HashSet; +import java.util.Set; + +/** + * Model for publisher. + */ +@Entity +@Include(description = "A book publisher") +public class Publisher { + + private long id; + private String name; + private Set books = new HashSet<>(); + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String title) { + this.name = title; + } + + @OneToMany(mappedBy = "publisher") + public Set getBooks() { + return books; + } + + public void setBooks(Set books) { + this.books = books; + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/AsyncTest.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/AsyncTest.java index 2b86f28cab..2a36f7cba1 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/AsyncTest.java +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/AsyncTest.java @@ -289,6 +289,6 @@ public void apiDocsDocumentTest() { .body("tags.name", containsInAnyOrder("group", "argument", "metric", "dimension", "column", "table", "asyncQuery", "timeDimensionGrain", "timeDimension", "product", "playerCountry", "version", "playerStats", - "stats", "tableExport", "namespace", "tableSource")); + "stats", "tableExport", "namespace", "tableSource", "maintainer", "book", "publisher", "person")); } } diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/ControllerTest.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/ControllerTest.java index d881578191..250adb7411 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/ControllerTest.java +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/ControllerTest.java @@ -12,22 +12,30 @@ import static com.yahoo.elide.test.graphql.GraphQLDSL.query; import static com.yahoo.elide.test.graphql.GraphQLDSL.selection; import static com.yahoo.elide.test.graphql.GraphQLDSL.selections; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.atomicOperation; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.atomicOperations; import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attr; import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attributes; import static com.yahoo.elide.test.jsonapi.JsonApiDSL.data; import static com.yahoo.elide.test.jsonapi.JsonApiDSL.datum; import static com.yahoo.elide.test.jsonapi.JsonApiDSL.id; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.lid; import static com.yahoo.elide.test.jsonapi.JsonApiDSL.linkage; import static com.yahoo.elide.test.jsonapi.JsonApiDSL.links; import static com.yahoo.elide.test.jsonapi.JsonApiDSL.patchOperation; import static com.yahoo.elide.test.jsonapi.JsonApiDSL.patchSet; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.ref; import static com.yahoo.elide.test.jsonapi.JsonApiDSL.relation; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.relationship; import static com.yahoo.elide.test.jsonapi.JsonApiDSL.relationships; import static com.yahoo.elide.test.jsonapi.JsonApiDSL.resource; import static com.yahoo.elide.test.jsonapi.JsonApiDSL.type; import static com.yahoo.elide.test.jsonapi.elements.PatchOperationType.add; +import static com.yahoo.elide.test.jsonapi.elements.PatchOperationType.remove; +import static com.yahoo.elide.test.jsonapi.elements.PatchOperationType.replace; import static io.restassured.RestAssured.given; import static io.restassured.RestAssured.when; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.equalTo; @@ -39,6 +47,7 @@ import com.yahoo.elide.core.exceptions.HttpStatus; import com.yahoo.elide.spring.controllers.JsonApiController; import com.yahoo.elide.test.graphql.GraphQLDSL; +import com.yahoo.elide.test.jsonapi.elements.AtomicOperationCode; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonNode; @@ -46,7 +55,9 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.MethodOrderer; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.jdbc.Sql; @@ -57,6 +68,7 @@ import jakarta.ws.rs.core.MediaType; +import java.util.Map; /** * Example functional test. */ @@ -74,6 +86,7 @@ } ) @ActiveProfiles("default") +@TestMethodOrder(MethodOrderer.MethodName.class) public class ControllerTest extends IntegrationTest { private String baseUrl; @@ -197,6 +210,63 @@ public void jsonApiPatchTest() { .statusCode(HttpStatus.SC_OK); } + @Test + public void jsonApiPostLidTest() { + String personId = "1"; + given() + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body( + datum( + resource( + type("person"), + lid("0eeabd1d-70a9-4e9a-8734-9f2d6b43b2ea"), + attributes( + attr("firstName", "John"), + attr("lastName", "Doe") + ), + relationships( + relation("bestFriend", + true, + resource( + type("person"), + lid("0eeabd1d-70a9-4e9a-8734-9f2d6b43b2ea"))) + ) + ) + ) + ) + .when() + .post("/json/person") + .then() + .body(equalTo( + datum( + resource( + type("person"), + id(personId), + attributes( + attr("firstName", "John"), + attr("lastName", "Doe") + ), + links( + attr("self", baseUrl + "person/" + personId) + ), + relationships( + relation( + "bestFriend", + true, + links( + attr("self", baseUrl + "person/" + personId + "/relationships/bestFriend"), + attr("related", baseUrl + "person/" + personId + "/bestFriend") + ), + resource(type("person"), id(personId)) + + ) + ) + ) + ).toJSON()) + ) + .statusCode(HttpStatus.SC_CREATED); + } + @Test public void jsonForbiddenApiPatchTest() { given() @@ -221,7 +291,7 @@ public void jsonForbiddenApiPatchTest() { @Test public void jsonApiPatchExtensionTest() { - given() + ExtractableResponse response = given() .contentType(JsonApiController.JSON_API_PATCH_CONTENT_TYPE) .accept(JsonApiController.JSON_API_PATCH_CONTENT_TYPE) .body( @@ -229,18 +299,594 @@ public void jsonApiPatchExtensionTest() { patchOperation(add, "/group", resource( type("group"), - id("com.example.repository.foo"), + id("com.example.patch1"), attributes( attr("commonName", "Foo") ) ) + ), + patchOperation(add, "/group", + resource( + type("group"), + id("com.example.patch2"), + attributes( + attr("commonName", "Foo2") + ) + ) + ), + patchOperation(replace, "/group/com.example.patch2", + resource( + type("group"), + id("com.example.patch2"), + attributes( + attr("description", "Updated Description") + ) + ) ) ) ) .when() .patch("/json") .then() - .statusCode(HttpStatus.SC_OK); + .statusCode(HttpStatus.SC_OK) + .extract(); + String result = response.asString(); + String expected = """ + [{"data":{"type":"group","id":"com.example.patch1","attributes":{"commonName":"Foo","deprecated":false,"description":""},"relationships":{"products":{"links":{"self":"https://elide.io/json/group/com.example.patch1/relationships/products","related":"https://elide.io/json/group/com.example.patch1/products"},"data":[]}},"links":{"self":"https://elide.io/json/group/com.example.patch1"}}},{"data":{"type":"group","id":"com.example.patch2","attributes":{"commonName":"Foo2","deprecated":false,"description":"Updated Description"},"relationships":{"products":{"links":{"self":"https://elide.io/json/group/com.example.patch2/relationships/products","related":"https://elide.io/json/group/com.example.patch2/products"},"data":[]}},"links":{"self":"https://elide.io/json/group/com.example.patch2"}}},{"data":null}]"""; + assertEquals(expected, result); + + ExtractableResponse deleteResponse = given() + .contentType(JsonApiController.JSON_API_PATCH_CONTENT_TYPE) + .accept(JsonApiController.JSON_API_PATCH_CONTENT_TYPE) + .body( + patchSet( + patchOperation(remove, "/group", + resource( + type("group"), + id("com.example.patch1") + ) + ), + patchOperation(remove, "/group", + resource( + type("group"), + id("com.example.patch2") + ) + ) + ) + ) + .when() + .patch("/json") + .then() + .statusCode(HttpStatus.SC_OK) + .extract(); + String deleteResult = deleteResponse.asString(); + String deleteExpected = """ + [{"data":null},{"data":null}]"""; + assertEquals(deleteExpected, deleteResult); + } + + @Test + public void jsonApiAtomicOperationsExtensionTest() { + ExtractableResponse response = given() + .contentType(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .accept(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .body( + atomicOperations( + atomicOperation(AtomicOperationCode.add, "/group", + datum(resource( + type("group"), + id("com.example.operations1"), + attributes( + attr("commonName", "Foo1") + ) + )) + ), + atomicOperation(AtomicOperationCode.add, "/group", + datum(resource( + type("group"), + id("com.example.operations2"), + attributes( + attr("commonName", "Foo2") + ) + )) + ), + atomicOperation(AtomicOperationCode.update, "/group/com.example.operations2", + datum(resource( + type("group"), + id("com.example.operations2"), + attributes( + attr("description", "Updated Description") + ) + )) + ), + atomicOperation(AtomicOperationCode.add, "/group/com.example.operations2/products", + datum(resource( + type("product"), + id("com.example.operations.product1"), + attributes( + attr("commonName", "Product1") + ) + )) + ), + atomicOperation(AtomicOperationCode.update, "/group/com.example.operations2/products/com.example.operations.product1", + datum(resource( + type("product"), + id("com.example.operations.product1"), + attributes( + attr("description", "Product1 Description") + ) + )) + ) + ) + ) + .when() + .post("/json/operations") + .then() + .statusCode(HttpStatus.SC_OK) + .extract(); + + String result = response.asString(); + String expected = """ + {"atomic:results":[{"data":{"type":"group","id":"com.example.operations1","attributes":{"commonName":"Foo1","deprecated":false,"description":""},"relationships":{"products":{"links":{"self":"https://elide.io/json/group/com.example.operations1/relationships/products","related":"https://elide.io/json/group/com.example.operations1/products"},"data":[]}},"links":{"self":"https://elide.io/json/group/com.example.operations1"}}},{"data":{"type":"group","id":"com.example.operations2","attributes":{"commonName":"Foo2","deprecated":false,"description":"Updated Description"},"relationships":{"products":{"links":{"self":"https://elide.io/json/group/com.example.operations2/relationships/products","related":"https://elide.io/json/group/com.example.operations2/products"},"data":[{"type":"product","id":"com.example.operations.product1"}]}},"links":{"self":"https://elide.io/json/group/com.example.operations2"}}},{"data":null},{"data":{"type":"product","id":"com.example.operations.product1","attributes":{"commonName":"Product1","description":"Product1 Description"},"relationships":{"group":{"links":{"self":"https://elide.io/json/group/com.example.operations2/products/com.example.operations.product1/relationships/group","related":"https://elide.io/json/group/com.example.operations2/products/com.example.operations.product1/group"},"data":{"type":"group","id":"com.example.operations2"}},"maintainers":{"links":{"self":"https://elide.io/json/group/com.example.operations2/products/com.example.operations.product1/relationships/maintainers","related":"https://elide.io/json/group/com.example.operations2/products/com.example.operations.product1/maintainers"},"data":[]},"versions":{"links":{"self":"https://elide.io/json/group/com.example.operations2/products/com.example.operations.product1/relationships/versions","related":"https://elide.io/json/group/com.example.operations2/products/com.example.operations.product1/versions"},"data":[]}},"links":{"self":"https://elide.io/json/group/com.example.operations2/products/com.example.operations.product1"}}},{"data":null}]}"""; + assertEquals(expected, result); + + ExtractableResponse deleteResponse = given() + .contentType(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .accept(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .body( + atomicOperations( + atomicOperation(AtomicOperationCode.remove, + ref( + type("group"), + id("com.example.operations1") + ) + ), + atomicOperation(AtomicOperationCode.remove, + ref( + type("group"), + id("com.example.operations2") + ) + ) + ) + ) + .when() + .post("/json/operations") + .then() + .statusCode(HttpStatus.SC_OK) + .extract(); + String deleteResult = deleteResponse.asString(); + String deleteExpected = """ + {"atomic:results":[{"data":null},{"data":null}]}"""; + assertEquals(deleteExpected, deleteResult); + + } + + @Test + public void jsonApiAtomicOperationsExtensionPathInferTest() { + ExtractableResponse response = given() + .contentType(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .accept(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .body( + atomicOperations( + atomicOperation(AtomicOperationCode.add, + datum(resource( + type("group"), + id("com.example.operationsinfer1"), + attributes( + attr("commonName", "Foo1") + ) + )) + ), + atomicOperation(AtomicOperationCode.add, + datum(resource( + type("group"), + id("com.example.operationsinfer2"), + attributes( + attr("commonName", "Foo2") + ) + )) + ), + atomicOperation(AtomicOperationCode.update, + datum(resource( + type("group"), + id("com.example.operationsinfer2"), + attributes( + attr("description", "Updated Description") + ) + )) + ) + ) + ) + .when() + .post("/json/operations") + .then() + .statusCode(HttpStatus.SC_OK) + .extract(); + String result = response.asString(); + String expected = """ + {"atomic:results":[{"data":{"type":"group","id":"com.example.operationsinfer1","attributes":{"commonName":"Foo1","deprecated":false,"description":""},"relationships":{"products":{"links":{"self":"https://elide.io/json/group/com.example.operationsinfer1/relationships/products","related":"https://elide.io/json/group/com.example.operationsinfer1/products"},"data":[]}},"links":{"self":"https://elide.io/json/group/com.example.operationsinfer1"}}},{"data":{"type":"group","id":"com.example.operationsinfer2","attributes":{"commonName":"Foo2","deprecated":false,"description":"Updated Description"},"relationships":{"products":{"links":{"self":"https://elide.io/json/group/com.example.operationsinfer2/relationships/products","related":"https://elide.io/json/group/com.example.operationsinfer2/products"},"data":[]}},"links":{"self":"https://elide.io/json/group/com.example.operationsinfer2"}}},{"data":null}]}"""; + assertEquals(expected, result); + + ExtractableResponse deleteResponse = given() + .contentType(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .accept(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .body( + atomicOperations( + atomicOperation(AtomicOperationCode.remove, + ref( + type("group"), + id("com.example.operationsinfer1") + ) + ), + atomicOperation(AtomicOperationCode.remove, + ref( + type("group"), + id("com.example.operationsinfer2") + ) + ) + ) + ) + .when() + .post("/json/operations") + .then() + .statusCode(HttpStatus.SC_OK) + .extract(); + String deleteResult = deleteResponse.asString(); + String deleteExpected = """ + {"atomic:results":[{"data":null},{"data":null}]}"""; + assertEquals(deleteExpected, deleteResult); + } + + @Test + public void jsonApiAtomicOperationsExtensionLidTest() { + ExtractableResponse response = given() + .contentType(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .accept(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .body( + atomicOperations( + atomicOperation(AtomicOperationCode.add, + datum(resource( + type("book"), + lid("f950e6f7-d392-4671-bacd-80966fa3ed5c"), + attributes( + attr("title", "Book 1") + ) + )) + ), + atomicOperation(AtomicOperationCode.add, + datum(resource( + type("book"), + lid("d4fd57f0-3127-4bb6-b4c9-13bc78341737"), + attributes( + attr("title", "Book 2") + ) + )) + ), + atomicOperation(AtomicOperationCode.add, + datum(resource( + type("publisher"), + lid("ed615b7a-b551-4a2d-aa12-56154c1c44aa"), + attributes( + attr("name", "Publisher") + ) + )) + ), + atomicOperation(AtomicOperationCode.update, + ref(type("book"), lid("d4fd57f0-3127-4bb6-b4c9-13bc78341737")), + datum(resource( + type("book"), + lid("d4fd57f0-3127-4bb6-b4c9-13bc78341737"), + attributes( + attr("title", "Book 2 Updated") + ) + )) + ), + atomicOperation(AtomicOperationCode.add, + ref(type("publisher"), lid("ed615b7a-b551-4a2d-aa12-56154c1c44aa"), relationship("books")), + data(resource( + type("book"), + lid("f950e6f7-d392-4671-bacd-80966fa3ed5c") + ), resource( + type("book"), + lid("d4fd57f0-3127-4bb6-b4c9-13bc78341737") + )) + ) + ) + ) + .when() + .post("/json/operations") + .then() + .statusCode(HttpStatus.SC_OK) + .extract(); + String result = response.asString(); + String expected = """ + {"atomic:results":[{"data":{"type":"book","id":"1","attributes":{"title":"Book 1"},"relationships":{"publisher":{"links":{"self":"https://elide.io/json/book/1/relationships/publisher","related":"https://elide.io/json/book/1/publisher"},"data":{"type":"publisher","id":"1"}}},"links":{"self":"https://elide.io/json/book/1"}}},{"data":{"type":"book","id":"2","attributes":{"title":"Book 2 Updated"},"relationships":{"publisher":{"links":{"self":"https://elide.io/json/book/2/relationships/publisher","related":"https://elide.io/json/book/2/publisher"},"data":{"type":"publisher","id":"1"}}},"links":{"self":"https://elide.io/json/book/2"}}},{"data":{"type":"publisher","id":"1","attributes":{"name":"Publisher"},"relationships":{"books":{"links":{"self":"https://elide.io/json/publisher/1/relationships/books","related":"https://elide.io/json/publisher/1/books"},"data":[{"type":"book","id":"1"},{"type":"book","id":"2"}]}},"links":{"self":"https://elide.io/json/publisher/1"}}},{"data":null},{"data":null}]}"""; + assertEquals(expected, result); + String bookId1 = response.path("'atomic:results'[0].data.id"); + String bookId2 = response.path("'atomic:results'[1].data.id"); + String publisherId = response.path("'atomic:results'[2].data.id"); + + ExtractableResponse deleteResponse = given() + .contentType(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .accept(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .body( + atomicOperations( + atomicOperation(AtomicOperationCode.remove, + ref( + type("book"), + id(bookId1) + ) + ), + atomicOperation(AtomicOperationCode.remove, + ref( + type("book"), + id(bookId2) + ) + ), + atomicOperation(AtomicOperationCode.remove, + ref( + type("publisher"), + id(publisherId) + ) + ) + ) + ) + .when() + .post("/json/operations") + .then() + .statusCode(HttpStatus.SC_OK) + .extract(); + String deleteResult = deleteResponse.asString(); + String deleteExpected = """ + {"atomic:results":[{"data":null},{"data":null},{"data":null}]}"""; + assertEquals(deleteExpected, deleteResult); + } + + + @Test + public void jsonApiAtomicOperationsExtensionRelationshipTest() { + ExtractableResponse response = given() + .contentType(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .accept(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .body( + atomicOperations( + atomicOperation(AtomicOperationCode.add, + datum(resource( + type("group"), + id("com.example.operationsrel1"), + attributes( + attr("commonName", "Foo1") + ) + )) + ), + atomicOperation(AtomicOperationCode.add, + datum(resource( + type("maintainer"), + id("com.example.person1"), + attributes( + attr("commonName", "Person1") + ) + )) + ), + atomicOperation(AtomicOperationCode.add, "/group/com.example.operationsrel1/products", + datum(resource( + type("product"), + id("com.example.operations.product1"), + attributes( + attr("commonName", "Product1") + ) + )) + ), + atomicOperation(AtomicOperationCode.add, + "/group/com.example.operationsrel1/products/com.example.operations.product1/relationships/maintainers", + data(resource( + type("maintainer"), + id("com.example.person1") + )) + ) + ) + ) + .when() + .post("/json/operations") + .then() + .statusCode(HttpStatus.SC_OK) + .extract(); + String result = response.asString(); + String expected = """ + {"atomic:results":[{"data":{"type":"group","id":"com.example.operationsrel1","attributes":{"commonName":"Foo1","deprecated":false,"description":""},"relationships":{"products":{"links":{"self":"https://elide.io/json/group/com.example.operationsrel1/relationships/products","related":"https://elide.io/json/group/com.example.operationsrel1/products"},"data":[{"type":"product","id":"com.example.operations.product1"}]}},"links":{"self":"https://elide.io/json/group/com.example.operationsrel1"}}},{"data":{"type":"maintainer","id":"com.example.person1","attributes":{"commonName":"Person1","description":""},"relationships":{"products":{"links":{"self":"https://elide.io/json/maintainer/com.example.person1/relationships/products","related":"https://elide.io/json/maintainer/com.example.person1/products"},"data":[]}},"links":{"self":"https://elide.io/json/maintainer/com.example.person1"}}},{"data":{"type":"product","id":"com.example.operations.product1","attributes":{"commonName":"Product1","description":""},"relationships":{"group":{"links":{"self":"https://elide.io/json/group/com.example.operationsrel1/products/com.example.operations.product1/relationships/group","related":"https://elide.io/json/group/com.example.operationsrel1/products/com.example.operations.product1/group"},"data":{"type":"group","id":"com.example.operationsrel1"}},"maintainers":{"links":{"self":"https://elide.io/json/group/com.example.operationsrel1/products/com.example.operations.product1/relationships/maintainers","related":"https://elide.io/json/group/com.example.operationsrel1/products/com.example.operations.product1/maintainers"},"data":[{"type":"maintainer","id":"com.example.person1"}]},"versions":{"links":{"self":"https://elide.io/json/group/com.example.operationsrel1/products/com.example.operations.product1/relationships/versions","related":"https://elide.io/json/group/com.example.operationsrel1/products/com.example.operations.product1/versions"},"data":[]}},"links":{"self":"https://elide.io/json/group/com.example.operationsrel1/products/com.example.operations.product1"}}},{"data":null}]}"""; + assertEquals(expected, result); + + ExtractableResponse deleteResponse = given() + .contentType(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .accept(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .body( + atomicOperations( + atomicOperation(AtomicOperationCode.remove, + "/group/com.example.operationsrel1/products/com.example.operations.product1/relationships/maintainers", + data(resource(type("maintainer"), id("com.example.person1"))) + ), + atomicOperation(AtomicOperationCode.remove, + ref( + type("group"), + lid("com.example.operationsrel1"), + relationship("products") + ), + data(resource(type("product"), id("com.example.operations.product1"))) + ), + atomicOperation(AtomicOperationCode.remove, + ref( + type("maintainer"), + id("com.example.person1") + ) + ), + atomicOperation(AtomicOperationCode.remove, + ref( + type("group"), + id("com.example.operationsrel1") + ) + ) + ) + ) + .when() + .post("/json/operations") + .then() + .statusCode(HttpStatus.SC_OK) + .extract(); + String deleteResult = deleteResponse.asString(); + String deleteExpected = """ + {"atomic:results":[{"data":null},{"data":null},{"data":null},{"data":null}]}"""; + assertEquals(deleteExpected, deleteResult); + } + + @Test + public void jsonApiAtomicOperationsExtensionMissingRefTypeTest() { + ExtractableResponse response = given() + .contentType(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .accept(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .body( + atomicOperations( + atomicOperation(AtomicOperationCode.add, + ref( + type(null), + id("com.example.operations.product1"), + relationship("maintainers") + ) + ) + ) + ) + .when() + .post("/json/operations") + .then() + .statusCode(HttpStatus.SC_BAD_REQUEST) + .extract(); + Map attributes = response.path("[0].errors[0]"); + assertThat(attributes).extractingByKeys("detail", "status").contains( + "Bad Request Body'Atomic Operations extension ref must specify the type member.'", "400"); + } + + @Test + public void jsonApiAtomicOperationsExtensionMissingRefAndHrefTest() { + ExtractableResponse response = given() + .contentType(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .accept(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .body( + atomicOperations( + atomicOperation(AtomicOperationCode.add, + (String) null, null + ) + ) + ) + .when() + .post("/json/operations") + .then() + .statusCode(HttpStatus.SC_BAD_REQUEST) + .extract(); + Map attributes = response.path("[0].errors[0]"); + assertThat(attributes).extractingByKeys("detail", "status").contains( + "Bad Request Body'Atomic Operations extension operation requires either ref or href members to be specified.'", "400"); + } + + @Test + public void jsonApiAtomicOperationsExtensionUnsupportedOpTest() { + String body = """ + { + "atomic:operations": [{ + "op": "", + "ref": { + "type": "articles", + "id": "1", + "relationship": "comments" + }, + "data": [ + { "type": "comments", "id": "123" } + ] + }] + } + """; + + ExtractableResponse response = given() + .contentType(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .accept(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .body(body) + .when() + .post("/json/operations") + .then() + .statusCode(HttpStatus.SC_BAD_REQUEST) + .extract(); + Map attributes = response.path("errors[0]"); + assertThat(attributes).extractingByKeys("detail").contains( + "Bad Request Body'Invalid Atomic Operations extension operation code:<script src=''></script>'"); + } + + @Test + public void jsonApiAtomicOperationsExtensionMissingOpTest() { + String body = """ + { + "atomic:operations": [{ + "ref": { + "type": "articles", + "id": "1", + "relationship": "comments" + }, + "data": [ + { "type": "comments", "id": "123" } + ] + }] + } + """; + + ExtractableResponse response = given() + .contentType(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .accept(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .body(body) + .when() + .post("/json/operations") + .then() + .statusCode(HttpStatus.SC_BAD_REQUEST) + .extract(); + Map attributes = response.path("[0].errors[0]"); + assertThat(attributes).extractingByKeys("detail", "status").contains( + "Bad Request Body'Atomic Operations extension operation code must be specified.'", "400"); + } + + @Test + public void jsonApiAtomicOperationsExtensionMissingOperationsTest() { + String body = """ + { + "atomic:operations": [null] + } + """; + + ExtractableResponse response = given() + .contentType(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .accept(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .body(body) + .when() + .post("/json/operations") + .then() + .statusCode(HttpStatus.SC_BAD_REQUEST) + .extract(); + Map attributes = response.path("[0].errors[0]"); + assertThat(attributes).extractingByKeys("detail", "status").contains( + "Bad Request Body'Atomic Operations extension operation must be specified.'", "400"); + } + + @Test + public void jsonApiAtomicOperationsExtensionInvalidFormatTest() { + String body = """ + {""}"""; + ExtractableResponse response = given() + .contentType(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .accept(JsonApiController.JSON_API_ATOMIC_OPERATIONS_CONTENT_TYPE) + .body(body) + .when() + .post("/json/operations") + .then() + .statusCode(HttpStatus.SC_BAD_REQUEST) + .extract(); + Map attributes = response.path("errors[0]"); + assertThat(attributes).extractingByKeys("detail").contains( + "Bad Request Body'{"<script src=''></script>"}'"); } @Test @@ -439,7 +1085,7 @@ public void apiDocsDocumentTest() { .body("tags.name", containsInAnyOrder("group", "argument", "metric", "dimension", "column", "table", "asyncQuery", "timeDimensionGrain", "timeDimension", "product", "playerCountry", "version", "playerStats", - "stats", "namespace", "tableSource")); + "stats", "namespace", "tableSource", "maintainer", "book", "publisher", "person")); } @Test diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/DisableAggStoreControllerTest.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/DisableAggStoreControllerTest.java index 6816789d5b..8d0ee4efe8 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/DisableAggStoreControllerTest.java +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/DisableAggStoreControllerTest.java @@ -29,6 +29,7 @@ public void apiDocsDocumentTest() { .get("/doc") .then() .statusCode(HttpStatus.SC_OK) - .body("tags.name", containsInAnyOrder("group", "asyncQuery", "product", "version")); + .body("tags.name", containsInAnyOrder("group", "asyncQuery", "product", "version", "maintainer", "book", + "publisher", "person")); } } diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/DisableMetaDataStoreControllerTest.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/DisableMetaDataStoreControllerTest.java index 62558e6ae5..18ea3c9726 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/DisableMetaDataStoreControllerTest.java +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/DisableMetaDataStoreControllerTest.java @@ -31,6 +31,6 @@ public void apiDocsDocumentTest() { .then() .statusCode(HttpStatus.SC_OK) .body("tags.name", containsInAnyOrder("playerCountry", "version", - "asyncQuery", "playerStats", "stats", "product", "group")); + "asyncQuery", "playerStats", "stats", "product", "group", "maintainer", "book", "publisher", "person")); } } diff --git a/elide-standalone/src/test/java/example/ElideStandaloneTest.java b/elide-standalone/src/test/java/example/ElideStandaloneTest.java index b1760c495b..13e80eb6ad 100644 --- a/elide-standalone/src/test/java/example/ElideStandaloneTest.java +++ b/elide-standalone/src/test/java/example/ElideStandaloneTest.java @@ -6,12 +6,15 @@ package example; import static com.yahoo.elide.Elide.JSONAPI_CONTENT_TYPE; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.atomicOperation; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.atomicOperations; import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attr; import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attributes; import static com.yahoo.elide.test.jsonapi.JsonApiDSL.data; import static com.yahoo.elide.test.jsonapi.JsonApiDSL.datum; import static com.yahoo.elide.test.jsonapi.JsonApiDSL.id; import static com.yahoo.elide.test.jsonapi.JsonApiDSL.links; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.ref; import static com.yahoo.elide.test.jsonapi.JsonApiDSL.resource; import static com.yahoo.elide.test.jsonapi.JsonApiDSL.type; import static io.restassured.RestAssured.given; @@ -25,8 +28,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import com.yahoo.elide.jsonapi.JsonApi; import com.yahoo.elide.standalone.ElideStandalone; import com.yahoo.elide.standalone.config.ElideStandaloneSettings; +import com.yahoo.elide.test.jsonapi.elements.AtomicOperationCode; import org.apache.http.HttpStatus; import org.junit.jupiter.api.AfterAll; @@ -102,6 +107,72 @@ public void testJsonAPIPost() { ).toJSON() ) ); + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .delete("/api/v1/post/1") + .then() + .statusCode(HttpStatus.SC_NO_CONTENT); + } + + @Test + public void testJsonAPIAtomicOperations() { + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body( + atomicOperations( + atomicOperation(AtomicOperationCode.add, "/post", + datum(resource( + type("post"), + id("10"), + attributes( + attr("content", "This is my second post. woot.") + ) + )) + ) + ) + ) + .post("/api/v1/operations") + .then() + .statusCode(HttpStatus.SC_OK); + + given() + .when() + .get("/api/v1/post/10") + .then() + .statusCode(200) + .body(equalTo( + datum( + resource( + type("post"), + id("10"), + attributes( + attr("abusiveContent", false), + attr("content", "This is my second post. woot."), + attr("date", null) + ), + links( + attr("self", "https://elide.io/api/v1/post/10") + ) + ) + ).toJSON() + ) + ); + + given() + .contentType(JsonApi.AtomicOperations.MEDIA_TYPE) + .accept(JsonApi.AtomicOperations.MEDIA_TYPE) + .body( + atomicOperations( + atomicOperation(AtomicOperationCode.remove, + ref(type("post"), id("10"))) + ) + ) + .post("/api/v1/operations") + .then() + .statusCode(HttpStatus.SC_OK); } @Test diff --git a/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/JsonApiDSL.java b/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/JsonApiDSL.java index 8b413b4cec..b4c0b5d3f4 100644 --- a/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/JsonApiDSL.java +++ b/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/JsonApiDSL.java @@ -6,17 +6,23 @@ package com.yahoo.elide.test.jsonapi; +import com.yahoo.elide.test.jsonapi.elements.AtomicOperation; +import com.yahoo.elide.test.jsonapi.elements.AtomicOperationCode; +import com.yahoo.elide.test.jsonapi.elements.AtomicOperations; import com.yahoo.elide.test.jsonapi.elements.Attribute; import com.yahoo.elide.test.jsonapi.elements.Attributes; import com.yahoo.elide.test.jsonapi.elements.Data; import com.yahoo.elide.test.jsonapi.elements.Document; import com.yahoo.elide.test.jsonapi.elements.Id; import com.yahoo.elide.test.jsonapi.elements.Include; +import com.yahoo.elide.test.jsonapi.elements.Lid; import com.yahoo.elide.test.jsonapi.elements.Links; import com.yahoo.elide.test.jsonapi.elements.PatchOperation; import com.yahoo.elide.test.jsonapi.elements.PatchOperationType; import com.yahoo.elide.test.jsonapi.elements.PatchSet; +import com.yahoo.elide.test.jsonapi.elements.Ref; import com.yahoo.elide.test.jsonapi.elements.Relation; +import com.yahoo.elide.test.jsonapi.elements.Relationship; import com.yahoo.elide.test.jsonapi.elements.Relationships; import com.yahoo.elide.test.jsonapi.elements.Resource; import com.yahoo.elide.test.jsonapi.elements.ResourceLinkage; @@ -51,6 +57,16 @@ public static Data data(Resource... resources) { return new Data(resources); } + /** + * Data data. + * + * @param resources the resources + * @return a data + */ + public static Data datum(Resource resources) { + return new Data(resources); + } + /** * Include data. * @@ -105,6 +121,19 @@ public static Resource resource(Type type, Id id, Attributes attributes, Relatio return new Resource(id, type, attributes, null, relationships); } + /** + * Resource resource. + * + * @param type the type + * @param lid the lid + * @param attributes the attributes + * @param relationships the relationships + * @return the resource + */ + public static Resource resource(Type type, Lid lid, Attributes attributes, Relationships relationships) { + return new Resource(lid, type, attributes, null, relationships); + } + /** * Resource resource. * @@ -144,6 +173,18 @@ public static Resource resource(Type type, Id id, Attributes attributes) { return new Resource(id, type, attributes, null, null); } + /** + * Resource resource. + * + * @param type the type + * @param lid the lid + * @param attributes the attributes + * @return the resource + */ + public static Resource resource(Type type, Lid lid, Attributes attributes) { + return new Resource(lid, type, attributes, null, null); + } + /** * Resource resource. * @@ -167,6 +208,17 @@ public static Resource resource(Type type, Id id) { return new Resource(id, type, null, null, null); } + /** + * Resource resource. + * + * @param type the type + * @param lid the lid + * @return the resource + */ + public static Resource resource(Type type, Lid lid) { + return new Resource(lid, type, null, null, null); + } + /** * Resource resource. * @@ -210,6 +262,26 @@ public static Id id(Object id) { return new Id(id); } + /** + * Local Id id. + * + * @param lid the lid + * @return the lid + */ + public static Lid lid(Object lid) { + return new Lid(lid); + } + + /** + * Relationship relationship. + * + * @param value the value + * @return the relationship + */ + public static Relationship relationship(String value) { + return new Relationship(value); + } + /** * Attributes attributes. * @@ -295,6 +367,31 @@ public static Relation relation(String field, Links links) { return new Relation(field, links); } + /** + * Relation relation. + * + * @param field the field + * @param toOne whether or not this is a toOne or toMany relationship + * @param links the links + * @return the relation + */ + public static Relation relation(String field, boolean toOne, Links links) { + return new Relation(field, toOne, links); + } + + /** + * Relation relation. + * + * @param field the field + * @param toOne whether or not this is a toOne or toMany relationship + * @param links the links + * @param resourceLinkage the resource linkage + * @return the relation + */ + public static Relation relation(String field, boolean toOne, Links links, ResourceLinkage... resourceLinkage) { + return new Relation(field, toOne, links, resourceLinkage); + } + /** * Relation relation. * @@ -349,4 +446,40 @@ public static PatchSet patchSet(PatchOperation... patchOperations) { public static PatchOperation patchOperation(PatchOperationType operation, String path, Resource value) { return new PatchOperation(operation, path, value); } + + public static AtomicOperations atomicOperations(AtomicOperation... atomicOperations) { + return new AtomicOperations(atomicOperations); + } + + public static AtomicOperation atomicOperation(AtomicOperationCode operation, Data data) { + return new AtomicOperation(operation, data); + } + + public static AtomicOperation atomicOperation(AtomicOperationCode operation, String href, Data data) { + return new AtomicOperation(operation, href, data); + } + + public static AtomicOperation atomicOperation(AtomicOperationCode operation, Ref ref, Data data) { + return new AtomicOperation(operation, ref, data); + } + + public static AtomicOperation atomicOperation(AtomicOperationCode operation, Ref ref) { + return new AtomicOperation(operation, ref, null); + } + + public static Ref ref(Type type, Id id) { + return new Ref(type, id); + } + + public static Ref ref(Type type, Id id, Relationship relationship) { + return new Ref(type, id, relationship); + } + + public static Ref ref(Type type, Lid lid) { + return new Ref(type, lid); + } + + public static Ref ref(Type type, Lid lid, Relationship relationship) { + return new Ref(type, lid, relationship); + } } diff --git a/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/AtomicOperation.java b/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/AtomicOperation.java new file mode 100644 index 0000000000..5a7347cda0 --- /dev/null +++ b/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/AtomicOperation.java @@ -0,0 +1,58 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.test.jsonapi.elements; + +import java.util.LinkedHashMap; + +/** + * Atomic operation. + */ +public class AtomicOperation extends LinkedHashMap { + + /** + * Atomic Operation. + * + * @param operation the operation type + * @param href the operation path + * @param data the operation value + */ + public AtomicOperation(AtomicOperationCode operation, String href, Data data) { + this.put("op", operation.name()); + this.put("href", href); + if (data != null) { + this.put("data", data.get("data")); + } + } + + /** + * Atomic Operation. + * + * @param operation the operation type + * @param ref the operation path + * @param data the operation value + */ + public AtomicOperation(AtomicOperationCode operation, Ref ref, Data data) { + this.put("op", operation.name()); + this.put("ref", ref); + if (data != null) { + this.put("data", data.get("data")); + } + } + + /** + * Atomic Operation. + * + * @param operation the operation type + * @param data the operation value + */ + public AtomicOperation(AtomicOperationCode operation, Data data) { + this.put("op", operation.name()); + if (data != null) { + this.put("data", data.get("data")); + } + } +} diff --git a/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/AtomicOperationCode.java b/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/AtomicOperationCode.java new file mode 100644 index 0000000000..0f4b24fcc6 --- /dev/null +++ b/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/AtomicOperationCode.java @@ -0,0 +1,13 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.test.jsonapi.elements; + +/** + * Atomic Operation Code enum. + */ +public enum AtomicOperationCode { + add, remove, update +} diff --git a/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/AtomicOperations.java b/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/AtomicOperations.java new file mode 100644 index 0000000000..e08da9fcae --- /dev/null +++ b/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/AtomicOperations.java @@ -0,0 +1,43 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.test.jsonapi.elements; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; + +/** + * Atomic Operations. + */ +public class AtomicOperations extends HashMap> { + + private static final long serialVersionUID = 1L; + + private static final Gson GSON_INSTANCE = new GsonBuilder() + .serializeNulls().create(); + + /** + * Patch Set. + * + * @param atomicOperations the set of patch operations + */ + public AtomicOperations(AtomicOperation... atomicOperations) { + this.put("atomic:operations", Arrays.asList(atomicOperations)); + } + + /** + * To json string. + * + * @return the string + */ + public String toJSON() { + return GSON_INSTANCE.toJson(this); + } +} diff --git a/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/Lid.java b/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/Lid.java new file mode 100644 index 0000000000..fd9aa3694a --- /dev/null +++ b/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/Lid.java @@ -0,0 +1,20 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.test.jsonapi.elements; + +import lombok.AllArgsConstructor; + +/** + * The type Lid for Local ID. + */ +@AllArgsConstructor +public class Lid { + /** + * The Value. + */ + final Object value; +} diff --git a/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/Ref.java b/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/Ref.java new file mode 100644 index 0000000000..412fd48e7f --- /dev/null +++ b/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/Ref.java @@ -0,0 +1,63 @@ +/* + * Copyright 2023, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.test.jsonapi.elements; + +import java.util.LinkedHashMap; + +/** + * Atomic operation reference. + */ +public class Ref extends LinkedHashMap { + + /** + * Atomic Operation Reference. + * + * @param type the type + * @param id the id + */ + public Ref(Type type, Id id) { + this.put("type", type.value); + this.put("id", id.value); + } + + /** + * Atomic Operation Reference. + * + * @param type the type + * @param id the id + * @param relationship the relationship + */ + public Ref(Type type, Id id, Relationship relationship) { + this.put("type", type.value); + this.put("id", id.value); + this.put("relationship", relationship.value); + } + + /** + * Atomic Operation Reference. + * + * @param type the type + * @param lid the lid + */ + public Ref(Type type, Lid lid) { + this.put("type", type.value); + this.put("lid", lid.value); + } + + /** + * Atomic Operation Reference. + * + * @param type the type + * @param lid the lid + * @param relationship the relationship + */ + public Ref(Type type, Lid lid, Relationship relationship) { + this.put("type", type.value); + this.put("lid", lid.value); + this.put("relationship", relationship.value); + } +} diff --git a/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/Relationship.java b/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/Relationship.java new file mode 100644 index 0000000000..b46e9051bb --- /dev/null +++ b/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/Relationship.java @@ -0,0 +1,20 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.test.jsonapi.elements; + +import lombok.AllArgsConstructor; + +/** + * The type Relationship. + */ +@AllArgsConstructor +public class Relationship { + /** + * The Value. + */ + final Object value; +} diff --git a/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/Resource.java b/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/Resource.java index 9b58e1ee64..5a0a2c5815 100644 --- a/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/Resource.java +++ b/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/Resource.java @@ -32,4 +32,26 @@ public Resource(Id id, Type type, Attributes attributes, Links links, Relationsh this.put("links", links); } } + + /** + * Instantiates a new Resource. + * + * @param lid the lid + * @param type the type + * @param attributes the attributes + * @param links the links + * @param relationships the relationships + */ + public Resource(Lid lid, Type type, Attributes attributes, Links links, Relationships relationships) { + super(lid, type); + if (attributes != null) { + this.put("attributes", attributes); + } + if (relationships != null) { + this.put("relationships", relationships); + } + if (links != null) { + this.put("links", links); + } + } } diff --git a/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/ResourceLinkage.java b/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/ResourceLinkage.java index c89bd29679..3ec1b93a52 100644 --- a/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/ResourceLinkage.java +++ b/elide-test/src/main/java/com/yahoo/elide/test/jsonapi/elements/ResourceLinkage.java @@ -22,4 +22,15 @@ public ResourceLinkage(Id id, Type type) { this.put("type", type.value); this.put("id", id.value); } + + /** + * Instantiates a new Resource linkage. + * + * @param lid the lid + * @param type the type + */ + public ResourceLinkage(Lid lid, Type type) { + this.put("type", type.value); + this.put("lid", lid.value); + } } diff --git a/elide-test/src/test/java/com/yahoo/elide/test/jsonapi/JsonApiDSLTest.java b/elide-test/src/test/java/com/yahoo/elide/test/jsonapi/JsonApiDSLTest.java index 24e3a608e7..ae47420f5f 100644 --- a/elide-test/src/test/java/com/yahoo/elide/test/jsonapi/JsonApiDSLTest.java +++ b/elide-test/src/test/java/com/yahoo/elide/test/jsonapi/JsonApiDSLTest.java @@ -6,11 +6,24 @@ package com.yahoo.elide.test.jsonapi; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.atomicOperation; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attr; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attributes; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.datum; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.id; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.lid; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.linkage; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.links; import static com.yahoo.elide.test.jsonapi.JsonApiDSL.relation; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.relationships; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.resource; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.type; import static com.yahoo.elide.test.jsonapi.elements.PatchOperationType.add; import static com.yahoo.elide.test.jsonapi.elements.Relation.TO_ONE; import static org.junit.jupiter.api.Assertions.assertEquals; +import com.yahoo.elide.test.jsonapi.elements.AtomicOperationCode; + import org.junit.jupiter.api.Test; public class JsonApiDSLTest { @@ -380,4 +393,184 @@ public void verifyRequestWithLinksRelationship() { assertEquals(expected, actual); } + @Test + void verifyAtomicOperation() { + String expected = """ + {"atomic:operations":[{"op":"add","href":"/parent","data":{"type":"parent","id":"1","relationships":{"children":{"data":[{"type":"child","id":"2"}]},"spouses":{"data":[{"type":"parent","id":"3"}]}}}},{"op":"add","href":"/parent/1/children","data":{"type":"child","id":"2"}}]}"""; + String actual = JsonApiDSL.atomicOperations( + JsonApiDSL.atomicOperation(AtomicOperationCode.add, "/parent", + JsonApiDSL.datum( + JsonApiDSL.resource( + JsonApiDSL.type("parent"), + JsonApiDSL.id("1"), + JsonApiDSL.relationships( + JsonApiDSL.relation("children", + JsonApiDSL.linkage(JsonApiDSL.type("child"), JsonApiDSL.id("2")) + ), + JsonApiDSL.relation("spouses", + JsonApiDSL.linkage(JsonApiDSL.type("parent"), JsonApiDSL.id("3")) + ) + ) + )) + ), + JsonApiDSL.atomicOperation(AtomicOperationCode.add, "/parent/1/children", + JsonApiDSL.datum( + JsonApiDSL.resource( + JsonApiDSL.type("child"), + JsonApiDSL.id("2") + )) + ) + ).toJSON(); + assertEquals(expected, actual); + } + + @Test + void verifyAtomicOperationData() { + String expected = """ + {"atomic:operations":[{"op":"add","data":{"type":"article","id":"13","attributes":{"name":"article"}}}]}"""; + String actual = JsonApiDSL.atomicOperations( + atomicOperation(AtomicOperationCode.add, + datum(resource( + type("article"), + id("13"), + attributes( + attr("name", "article") + ) + )) + ) + ).toJSON(); + assertEquals(expected, actual); + } + + @Test + void verifyAtomicOperationDataLid() { + String expected = """ + {"atomic:operations":[{"op":"add","data":{"type":"article","lid":"13"}}]}"""; + String actual = JsonApiDSL.atomicOperations( + atomicOperation(AtomicOperationCode.add, + datum(resource( + type("article"), + lid("13") + )) + ) + ).toJSON(); + assertEquals(expected, actual); + } + + @Test + void verifyAtomicOperationDataAttributesLid() { + String expected = """ + {"atomic:operations":[{"op":"add","data":{"type":"article","lid":"13","attributes":{"name":"article"}}}]}"""; + String actual = JsonApiDSL.atomicOperations( + atomicOperation(AtomicOperationCode.add, + datum(resource( + type("article"), + lid("13"), + attributes( + attr("name", "article") + ) + )) + ) + ).toJSON(); + assertEquals(expected, actual); + } + + @Test + void verifyAtomicOperationDataAttributesRelationshipsLinksLid() { + String expected = """ + {"atomic:operations":[{"op":"add","data":{"type":"article","lid":"13","attributes":{"name":"article"},"relationships":{"post":{"links":{"self":"https://elide.io/group/com.example.repository2/relationships/products","related":"https://elide.io/group/com.example.repository2/products"},"data":null}}}}]}"""; + String actual = JsonApiDSL.atomicOperations( + atomicOperation(AtomicOperationCode.add, + datum(resource( + type("article"), + lid("13"), + attributes( + attr("name", "article") + ), + relationships(relation("post", true, + links( + attr("self", + "https://elide.io/" + + "group/com.example.repository2/relationships/products"), + attr("related", "https://elide.io/" + "group/com.example.repository2/products")) + + ))))) + ).toJSON(); + assertEquals(expected, actual); + } + + @Test + void verifyAtomicOperationDataAttributesRelationshipsLinksResourceLinkageLid() { + String expected = """ + {"atomic:operations":[{"op":"add","data":{"type":"article","lid":"13","attributes":{"name":"article"},"relationships":{"post":{"links":{"self":"https://elide.io/group/com.example.repository2/relationships/products","related":"https://elide.io/group/com.example.repository2/products"},"data":{"type":"group","id":"2"}}}}}]}"""; + String actual = JsonApiDSL.atomicOperations( + atomicOperation(AtomicOperationCode.add, + datum(resource( + type("article"), + lid("13"), + attributes( + attr("name", "article") + ), + relationships(relation("post", true, + links( + attr("self", + "https://elide.io/" + + "group/com.example.repository2/relationships/products"), + attr("related", "https://elide.io/" + "group/com.example.repository2/products")), + linkage(type("group"), id("2")) + + ))))) + ).toJSON(); + assertEquals(expected, actual); + } + + @Test + void verifyAtomicOperationRefId() { + String expected = """ + {"atomic:operations":[{"op":"remove","ref":{"type":"articles","id":"13"}}]}"""; + String actual = JsonApiDSL.atomicOperations( + JsonApiDSL.atomicOperation(AtomicOperationCode.remove, + JsonApiDSL.ref(JsonApiDSL.type("articles"), JsonApiDSL.id("13")) + ) + ).toJSON(); + assertEquals(expected, actual); + } + + @Test + void verifyAtomicOperationRefLid() { + String expected = """ + {"atomic:operations":[{"op":"remove","ref":{"type":"articles","lid":"13"}}]}"""; + String actual = JsonApiDSL.atomicOperations( + JsonApiDSL.atomicOperation(AtomicOperationCode.remove, + JsonApiDSL.ref(JsonApiDSL.type("articles"), JsonApiDSL.lid("13")) + ) + ).toJSON(); + assertEquals(expected, actual); + } + + @Test + void verifyAtomicOperationRefIdRelationship() { + String expected = """ + {"atomic:operations":[{"op":"update","ref":{"type":"articles","id":"13","relationship":"author"},"data":null}]}"""; + String actual = JsonApiDSL.atomicOperations( + JsonApiDSL.atomicOperation(AtomicOperationCode.update, + JsonApiDSL.ref(JsonApiDSL.type("articles"), JsonApiDSL.id("13"), JsonApiDSL.relationship("author")), + JsonApiDSL.datum(null) + ) + ).toJSON(); + assertEquals(expected, actual); + } + + @Test + void verifyAtomicOperationRefLidRelationship() { + String expected = """ + {"atomic:operations":[{"op":"update","ref":{"type":"articles","lid":"13","relationship":"author"},"data":null}]}"""; + String actual = JsonApiDSL.atomicOperations( + JsonApiDSL.atomicOperation(AtomicOperationCode.update, + JsonApiDSL.ref(JsonApiDSL.type("articles"), JsonApiDSL.lid("13"), JsonApiDSL.relationship("author")), + JsonApiDSL.datum(null) + ) + ).toJSON(); + assertEquals(expected, actual); + } }