From 27301f48e407aee17d51db306811df1d64f128d3 Mon Sep 17 00:00:00 2001 From: Khalid Qarryzada Date: Tue, 31 Mar 2026 14:06:08 -0700 Subject: [PATCH 1/5] Phase II: Bulk Operation Support This commit adds the second and final phase for supporting SCIM bulk request workflows for client and server applications. These changes build upon phase I by introducing the remaining support necessary for serialization/deserialization of bulk JSON data into Java objects, along with providing an interface for supporting these requests within applications. The primary reference point for information regarding bulk workflows has been placed in the BulkRequest class-level Javadoc. This provides the necessary context for what bulk requests are for, how to use them, and considerations to keep in mind. Special notes have been added for useful utilities in the API, such as a way to replace temporary bulk ID values after resources are created, as well as a powerful interface for extracting embedded SCIM resources and converting the JSON data into a usable Java object. Server applications are still responsible for handling bulk ID dependencies within a request, as well as evaluating whether a circular reference in the bulk request is unresolvable. However, a starting point for server-based processing logic has been provided in the BulkRequest documentation. Suggestions for features such as rate limiting have also been added to ensure server implementations consider these options. Reviewer: braveulysses Reviewer: dougbulkley Reviewer: selliott512 Reviewer: vyhhuang JiraIssue: DS-39451 --- CHANGELOG.md | 6 + .../scim2/common/bulk/BulkOperation.java | 915 ++++++++++++++++++ .../common/bulk/BulkOperationResult.java | 703 ++++++++++++++ .../scim2/common/bulk/BulkRequest.java | 622 +++++++++++- .../scim2/common/bulk/BulkResponse.java | 178 +++- .../exceptions/BulkRequestException.java | 82 ++ .../exceptions/BulkResponseException.java | 77 ++ .../scim2/common/utils/JsonUtils.java | 56 ++ .../common/bulk/BulkOperationResultTest.java | 584 +++++++++++ .../scim2/common/bulk/BulkOperationTest.java | 456 +++++++++ .../scim2/common/bulk/BulkRequestTest.java | 549 +++++++++++ .../scim2/common/bulk/BulkResponseTest.java | 186 ++++ .../unboundid/scim2/server/BulkEndpoint.java | 183 ++++ .../scim2/server/EndpointTestCase.java | 196 +++- 14 files changed, 4782 insertions(+), 11 deletions(-) create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/BulkRequestException.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/BulkResponseException.java create mode 100644 scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkOperationResultTest.java create mode 100644 scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkOperationTest.java create mode 100644 scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkRequestTest.java create mode 100644 scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkResponseTest.java create mode 100644 scim2-sdk-server/src/test/java/com/unboundid/scim2/server/BulkEndpoint.java diff --git a/CHANGELOG.md b/CHANGELOG.md index e8a7aa71..271e5e83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). ## v5.1.0 - TBD +Added support for bulk operations, requests, and responses as defined by the SCIM standard. To get +started with implementing bulk request support for client or server applications, see the +documentation in the `BulkRequest.java` class, which provides a comprehensive overview of how bulk +operations are defined and utilized in the SCIM standard. It also includes notes on how to interface +with the new classes, general structures for handling bulk data, useful utility methods, and more. + Updated Jackson to 2.21.2. Updated `ErrorResponse.java` to print attributes in an order that is more consistent with the diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperation.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperation.java index ab5e7b05..e9f7bd71 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperation.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperation.java @@ -32,6 +32,921 @@ package com.unboundid.scim2.common.bulk; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.unboundid.scim2.common.GenericScimResource; +import com.unboundid.scim2.common.Path; +import com.unboundid.scim2.common.ScimResource; +import com.unboundid.scim2.common.annotations.NotNull; +import com.unboundid.scim2.common.annotations.Nullable; +import com.unboundid.scim2.common.exceptions.BulkRequestException; +import com.unboundid.scim2.common.messages.PatchOperation; +import com.unboundid.scim2.common.messages.PatchRequest; +import com.unboundid.scim2.common.types.ETagConfig; +import com.unboundid.scim2.common.types.UserResource; +import com.unboundid.scim2.common.utils.Debug; +import com.unboundid.scim2.common.utils.DebugType; +import com.unboundid.scim2.common.utils.JsonUtils; + +import java.io.IOException; +import java.util.List; +import java.util.Objects; +import java.util.logging.Level; + +import static com.unboundid.scim2.common.utils.ApiConstants.BULK_PREFIX; +import static com.unboundid.scim2.common.utils.StaticUtils.toList; + + +/** + * This class represents a SCIM 2 bulk operation. A bulk operation is an + * individual write operation that is included within a {@link BulkRequest}. For + * an introduction to SCIM bulk requests and responses, see {@link BulkRequest}. + *

+ * + * A bulk operation can contain the following properties, which collectively + * specify information about the request. + * + *

+ * + * To construct a bulk operation, use one of the static methods defined on this + * class. Some examples include: + * + * + * Note that the {@code bulkId} and {@code version} fields may be set with the + * {@link #setBulkId} and {@link #setVersion} methods. + *

+ * + * The following JSON is an example bulk operation that updates an existing user + * resource. It may be included in a request to a SCIM service, alongside other + * bulk operations. + *
+ *   {
+ *     "method": "POST",
+ *     "path": "/Users/fa1afe1",
+ *     "bulkId": "qwerty",
+ *     "data": {
+ *       "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ],
+ *       "userName": "Eggs",
+ *       "active": true
+ *     }
+ *   }
+ * 
+ * + * The example JSON above can be created with the following Java code: + *

+ *   UserResource user = new UserResource().setUserName("Eggs").setActive(true);
+ *   BulkOperation op = BulkOperation.post("/Users", user).setBulkId("qwerty");
+ * 
+ *

+ * + * Because bulk operations can contain data of many forms, this class contains + * the {@link #getDataAsScimResource()} helper method to help easily extract the + * {@code data} field into a useful forms. + * + *

Bulk Patch Operations

+ * A bulk operation with a "patch" method is called a bulk patch operation. This + * is different from a standard patch operation, represented by the + * {@link PatchOperation} class (which is a subfield of a {@link PatchRequest}). + *

+ * + * It is important to note that RFC 7644 indicates that the {@code data} field + * for bulk patch operations are a list of operation objects. However, the + * example usages in the spec are not valid JSON because they do not form + * key-value pairs. For this reason, many SCIM implementations, including the + * UnboundID SCIM SDK, include an {@code "Operations"} identifier within bulk + * patch requests, similar to a {@link PatchRequest}. For example: + *
+ * {
+ *   "method": "PATCH",
+ *   "path": "/Users/5d8d29d3-342c-4b5f-8683-a3cb6763ffcc",
+ *   "data": {
+ *     "Operations": [ {
+ *       "op": "remove",
+ *       "path": "nickName"
+ *     }, {
+ *       "op": "replace",
+ *       "path": "userName",
+ *       "value": "Alice"
+ *     }
+ *   ] }
+ * }
+ * 
+ * + * Some services also include the {@code schemas} field of a patch request to + * fully match that data model. When fetching the {@code data} of a bulk patch + * operation with {@link #getDataAsScimResource()}, a {@link PatchRequest} + * object is always returned for simplicity. When bulk patch operations are + * serialized, they are always printed in the form shown above, without a + * {@code schemas} field. + * + * @see BulkRequest + * @since 5.1.0 + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "method") +@JsonSubTypes({ + @JsonSubTypes.Type(value = BulkOperation.PostOperation.class, + names = {"POST", "Post", "post"}), + @JsonSubTypes.Type(value = BulkOperation.PutOperation.class, + names = {"PUT", "Put", "put"}), + @JsonSubTypes.Type(value = BulkOperation.BulkPatchOperation.class, + names = {"PATCH", "Patch", "patch"}), + @JsonSubTypes.Type(value = BulkOperation.DeleteOperation.class, + names = {"DELETE", "Delete", "delete"}), +}) +@JsonPropertyOrder({ "method", "path", "bulkId", "version", "data" }) public abstract class BulkOperation { + /** + * A type reference for deserializing a JsonNode into a patch operation list. + */ + @NotNull + private static final TypeReference> PATCH_REF = + new TypeReference<>(){}; + + /** + * A flag that indicates whether the bulk operation contains a temporary + * {@code bulkId} field that will likely be replaced during processing. + */ + @JsonIgnore + private boolean hasBulkReference; + + /** + * This field represents the HTTP operation type (e.g., {@code POST}). + */ + @NotNull + private final BulkOpType method; + + /** + * The endpoint that should receive the request. For example, to create a new + * user, the value of this parameter should be {@code "/Users"}. Note that + * this is not a full URI. + *

+ * + * This field refers to an HTTP path/endpoint, and not an attribute path for a + * patch request (i.e., it is not a {@link Path}). + */ + @NotNull + private final String path; + + /** + * This field represents an optional bulk identifier field, which allows other + * operations in the same bulk request to reference this operation. For more + * information, view the class-level documentation. + */ + @Nullable + private String bulkId; + + /** + * The optional version tag, which can be used if the SCIM service provider + * supports ETag versioning. See {@link ETagConfig} for more information. + */ + @Nullable + private String version; + + /** + * The data field containing the contents of the write request. This field + * will be {@code null} only if this is a bulk delete operation. + */ + @Nullable + private final ObjectNode data; + + /** + * Sets the bulk ID of the bulk operation. Since bulk IDs may only be used for + * POST requests, this method throws an exception if this call does not target + * a {@code POST}. + * + * @param bulkId A bulk identifier that can allow other operations within + * a {@link BulkRequest} to reference the SCIM resource + * targeted by this bulk operation. + * @return This object. + * + * @throws BulkRequestException If a caller attempts setting a bulk ID value + * for a method other than POST. + */ + @NotNull + public BulkOperation setBulkId(@Nullable final String bulkId) + throws BulkRequestException + { + if (method != BulkOpType.POST) + { + throw new BulkRequestException( + "Bulk IDs may only be set for POST requests. Invalid HTTP method: " + + method); + } + + this.bulkId = bulkId; + return this; + } + + /** + * Sets the ETag version for the bulk operation. + *

+ * + * When a new resource is contained in a bulk request, it does not exist until + * after the bulk request has been processed successfully. Thus, a bulk post + * operation cannot reference any resource with a version tag, since the SCIM + * service has not yet assigned a version tag value. For this reason, calling + * this method for a bulk post operation is not permitted. + * + * @param version The version tag to use. + * @return This object. + * + * @throws BulkRequestException If a caller attempts setting a version value + * for a bulk POST request. + */ + @NotNull + public BulkOperation setVersion(@Nullable final String version) + throws BulkRequestException + { + if (method == BulkOpType.POST) + { + throw new BulkRequestException( + "Cannot set the 'version' field of a bulk POST operation."); + } + + this.version = version; + return this; + } + + /** + * Fetches the type of this bulk operation. + * + * @return The bulk operation type. + */ + @NotNull + @JsonIgnore + public BulkOpType getMethod() + { + return method; + } + + /** + * Fetches the HTTP path or endpoint targeted by this bulk operation (e.g., + * {@code /Users}). + * + * @return The endpoint. + */ + @NotNull + public String getPath() + { + return path; + } + + /** + * Fetches the {@code bulkId} of the bulk operation, if it exists. + * + * @return The bulk ID, or {@code null} if it is not set. + */ + @Nullable + public String getBulkId() + { + return bulkId; + } + + /** + * Fetches the ETag version associated with the bulk operation, if it exists. + * + * @return The version, or {@code null} if it is not set. + */ + @Nullable + public String getVersion() + { + return version; + } + + /** + * Fetches the {@code data} field representing the update data for a SCIM + * resource. + *

+ * + * This method returns the data as a JSON ObjectNode. If this field should be + * represented as a Java object, use {@link #getDataAsScimResource()} instead. + * + * @return The {@code data}. + */ + @Nullable + public ObjectNode getData() + { + return (data == null) ? null : data.deepCopy(); + } + + /** + * This utility method obtains the {@code data} field of this bulk operation + * and converts it to a {@link ScimResource}-based POJO. For example, a JSON + * representing a user resource with a schema of + * {@code urn:ietf:params:scim:schemas:core:2.0:User} would be returned as a + * {@link UserResource}. By default, if a suitable class cannot be found, a + * {@link GenericScimResource} instance will be returned. + *

+ * + * Note that patch operations are a special case due to their unique + * structure. The {@code data} for a patch operation is a list of + * {@link PatchOperation} objects, which is not a ScimResource. However, this + * is very close to a {@link PatchRequest}, which is likely a desirable form + * for SCIM server implementations. Thus, to simplify handling of the data for + * bulk patch operations, this method will return a {@link PatchRequest} + * instance for bulk patch operations. The list of patch operations may be + * obtained with {@link PatchRequest#getOperations()}. + *

+ * + * If you have custom classes that should be used, they may be registered with + * the {@link BulkResourceMapper}. However, any JSON object may be manually + * converted to a Java object with {@link JsonUtils#nodeToValue}: + *

+   *   ObjectNode data = bulkOperation.getData();
+   *   if (data != null)
+   *   {
+   *     return JsonUtils.nodeToValue(data, CustomResource.class);
+   *   }
+   * 
+ * + * For examples on how to use this method, see the class-level documentation + * of {@link BulkRequest}. + * + * @return The {@code data} field as an object of the specified class, or + * {@code null} for a delete request. + * + * @throws BulkRequestException If an error occurs while converting the + * object into a ScimResource type. + */ + @Nullable + @JsonIgnore + public ScimResource getDataAsScimResource() + throws BulkRequestException + { + if (this instanceof BulkPatchOperation) + { + return new PatchRequest(getPatchOperationList()); + } + + try + { + return BulkResourceMapper.asScimResource(data); + } + catch (IllegalArgumentException e) + { + throw new BulkRequestException( + "A malformed JSON failed to be converted into a SCIM resource.", e); + } + } + + /** + * Fetches the {@code data} field of the bulk operation as a list of patch + * operations. This method may only be used for bulk PATCH operations. + * + * @return The list of patch operations in the bulk operation. + * @throws IllegalStateException If this bulk operation is not a bulk PATCH. + */ + @NotNull + @JsonIgnore + protected List getPatchOperationList() + throws IllegalStateException + { + if (this instanceof BulkPatchOperation bulkPatchOp) + { + return bulkPatchOp.patchOperationList; + } + + // This is not a BulkRequestException since it cannot occur during + // deserialization. + throw new IllegalStateException("Attempted fetching patch data for a '" + + method + "' bulk operation."); + } + + /** + * Replaces any temporary bulk ID values in the {@code data} field with a + * real value. It is generally easier to call this on the BulkRequest object + * (with {@link BulkRequest#replaceBulkIdValues}) since this will apply the + * change to all operations within the bulk request. + *

+ * + * This method permanently alters the bulk operation directly, and it is not + * thread safe. + * + * @param bulkId The temporary bulk ID value. + * @param realValue The real value after the resource has been created. + */ + public void replaceBulkIdValue(@NotNull final String bulkId, + @NotNull final String realValue) + { + if (!hasBulkReference) + { + return; + } + Objects.requireNonNull(bulkId); + Objects.requireNonNull(realValue); + Objects.requireNonNull(data); + + // Recursively traverse the node and replace all text bulk ID references to + // the new value. This approach minimizes the amount of garbage generated. + JsonUtils.replaceAllTextValues(data, BULK_PREFIX + bulkId, realValue); + + hasBulkReference = data.toString().contains(BULK_PREFIX); + } + + /** + * Constructor for common settings that apply to all bulk operations. + * + * @param opType The bulk operation type. + * @param path The endpoint targeted by the request. + * @param data The JSON payload data. This may only be {@code null} for + * delete operations. + */ + private BulkOperation(@NotNull final BulkOpType opType, + @NotNull final String path, + @Nullable final ObjectNode data) + { + Objects.requireNonNull(path, + "The 'path' field cannot be null for a bulk operation."); + if (opType != BulkOpType.DELETE) + { + Objects.requireNonNull(data, + "The 'data' field cannot be null for a '" + opType + "' operation."); + } + + this.method = opType; + this.path = path; + this.data = data; + this.bulkId = null; + this.version = null; + + // Mark whether this operation contains bulk references for short-circuiting + // later. + hasBulkReference = data != null && data.toString().contains(BULK_PREFIX); + } + + /** + * This class represents a bulk POST operation. To instantiate an operation of + * this type, use {@link #post(String, ScimResource)}. + */ + protected static final class PostOperation extends BulkOperation + { + private PostOperation( + @NotNull @JsonProperty(value = "path", required = true) + final String path, + @NotNull @JsonProperty(value = "data", required = true) + final ObjectNode data) + { + super(BulkOpType.POST, path, data); + } + } + + /** + * This class represents a bulk PUT operation. To instantiate an operation of + * this type, use {@link #put(String, ScimResource)}. + */ + protected static final class PutOperation extends BulkOperation + { + private PutOperation( + @NotNull @JsonProperty(value = "path", required = true) + final String path, + @NotNull @JsonProperty(value = "data", required = true) + final ObjectNode data) + { + super(BulkOpType.PUT, path, data); + } + } + + /** + * This class represents a bulk PATCH operation. To instantiate an operation + * of this type, use {@link #patch(String, List)}. + *

+ * + * Implementation note: This class is named BulkPatchOperation to avoid + * confusion with {@link PatchOperation} objects. + */ + protected static final class BulkPatchOperation extends BulkOperation + { + @NotNull + private final List patchOperationList; + + private BulkPatchOperation( + @NotNull @JsonProperty(value = "path", required = true) + final String path, + @NotNull @JsonProperty(value = "data", required = true) + final ObjectNode data) + throws BulkRequestException + { + super(BulkOpType.PATCH, path, data); + patchOperationList = parseOperations(data); + } + + /** + * Converts a bulk patch operation's {@code data} field into a usable list + * of PatchOperations. + */ + private List parseOperations(@NotNull final ObjectNode data) + throws BulkRequestException + { + JsonNode operationsNode = data.get("Operations"); + if (operationsNode == null) + { + // This most likely indicates that the JSON is not a bulk operation, and + // instead represents some other data type. + throw new BulkRequestException( + "Could not parse the patch operation list from the bulk operation" + + " because the value of 'Operations' is absent."); + } + if (!(operationsNode instanceof ArrayNode arrayNode)) + { + throw new BulkRequestException( + "Could not parse the patch operation list from the bulk operation" + + " because the 'Operations' field is not an array."); + } + + try + { + // Convert the ArrayNode to a list of patch operations. + return JsonUtils.getObjectReader().forType(PATCH_REF) + .readValue(arrayNode); + } + catch (IOException e) + { + Debug.debugException(e); + throw new BulkRequestException( + "Failed to convert an array to a patch operation list.", e); + } + } + } + + + /** + * This class represents a bulk DELETE operation. To instantiate an operation + * of this type, use {@link #delete(String)}. + */ + protected static final class DeleteOperation extends BulkOperation + { + private DeleteOperation( + @NotNull @JsonProperty(value = "path", required = true) + final String path) + { + super(BulkOpType.DELETE, path, null); + } + } + + /** + * Constructs a bulk POST operation. The following example shows a JSON + * representation of a bulk POST operation which creates a user with a bulk ID + * value: + *
+   *  {
+   *    "method": "POST",
+   *    "path": "/Users",
+   *    "bulkId": "qwerty",
+   *    "data": {
+   *      "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ],
+   *      "userName": "Eggs",
+   *      "active": true
+   *    }
+   * }
+   * 
+ * + * To create this bulk operation, use the following Java code. + * + *

+   * UserResource user = new UserResource().setUserName("Eggs").setActive(true);
+   * BulkOperation op = BulkOperation.post("/Users", user).setBulkId("qwerty");
+   * 
+ * + * @param endpoint The endpoint/path that will receive the POST request, + * e.g., {@code /Users}. + * @param data The SCIM resource to create. + * + * @return The new bulk operation. + */ + @NotNull + public static BulkOperation post(@NotNull final String endpoint, + @NotNull final ScimResource data) + { + return post(endpoint, data.asGenericScimResource().getObjectNode()); + } + + /** + * Constructs a bulk POST operation. + * + * @param endpoint The endpoint/path that will receive the POST request, + * e.g., {@code /Users}. + * @param data The SCIM resource to create, in ObjectNode form. + * + * @return The new bulk operation. + */ + @NotNull + public static BulkOperation post(@NotNull final String endpoint, + @NotNull final ObjectNode data) + { + return new PostOperation(endpoint, data.deepCopy()); + } + + /** + * Constructs a bulk PUT operation. The following example shows a JSON + * representation of a bulk PUT operation: + *
+   *  {
+   *    "method": "PUT",
+   *    "path": "/Users/b7c14771-226c-4d05-8860-134711653041",
+   *    "data": {
+   *      "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ],
+   *      "id": "b7c14771-226c-4d05-8860-134711653041",
+   *      "userName": "Pseudo"
+   *    }
+   *  }
+   * 
+ * + * This operation can be created with the following Java code: + *

+   *   UserResource user = new UserResource().setUserName("Pseudo");
+   *   user.setId("b7c14771-226c-4d05-8860-134711653041");
+   *   BulkOperation.put("/Users/b7c14771-226c-4d05-8860-134711653041", user);
+   * 
+ * + * @param endpoint The endpoint/path that will receive the PUT request, e.g., + * {@code /Users/b7c14771-226c-4d05-8860-134711653041}. + * @param data The SCIM resource to create. + * + * @return The new bulk operation. + */ + @NotNull + public static BulkOperation put(@NotNull final String endpoint, + @NotNull final ScimResource data) + { + return put(endpoint, data.asGenericScimResource().getObjectNode()); + } + + /** + * Constructs a bulk PUT operation. + * + * @param endpoint The endpoint/path that will receive the PUT request, e.g., + * {@code /Users/b7c14771-226c-4d05-8860-134711653041}. + * @param data The SCIM resource to create. + * + * @return The new bulk operation. + */ + @NotNull + public static BulkOperation put(@NotNull final String endpoint, + @NotNull final ObjectNode data) + { + return new PutOperation(endpoint, data.deepCopy()); + } + + /** + * Constructs a bulk patch operation. For example: + *
+   * {
+   *   "method": "PATCH",
+   *   "path": "/Users/5d8d29d3-342c-4b5f-8683-a3cb6763ffcc",
+   *   "data": {
+   *     "Operations": [ {
+   *       "op": "remove",
+   *       "path": "nickName"
+   *     }, {
+   *       "op": "replace",
+   *       "path": "userName",
+   *       "value": "Alice"
+   *     }
+   *   ] }
+   * }
+   * 
+ * + * This operation can be created with the following Java code: + *

+   *   String httpPath = "/Users/5d8d29d3-342c-4b5f-8683-a3cb6763ffcc";
+   *   BulkOperation.patch(httpPath,
+   *       PatchOperation.remove("nickName"),
+   *       PatchOperation.replace("userName", "Alice")
+   *   );
+   * 
+ * + * @param endpoint The endpoint/path that will receive the PATCH request, + * e.g., {@code /Users/5d8d29d3-342c-4b5f-8683-a3cb6763ffcc}. + * @param op1 The first non-null patch operation to be provided in the + * bulk operation. + * @param ops An optional field for additional patch operations. Any + * {@code null} values will be ignored. + * + * @return The new bulk operation. + */ + @NotNull + public static BulkOperation patch(@NotNull final String endpoint, + @NotNull final PatchOperation op1, + @Nullable final PatchOperation... ops) + { + return patch(endpoint, toList(op1, ops)); + } + + /** + * Constructs a bulk patch operation. + * + * @param endpoint The endpoint/path that will receive the PATCH request, + * e.g., {@code /Users/5d8d29d3-342c-4b5f-8683-a3cb6763ffcc}. + * @param ops The list of patch operations to be provided in the bulk + * operation. + * + * @return The new bulk operation. + */ + @NotNull + public static BulkOperation patch(@NotNull final String endpoint, + @Nullable final List ops) + { + List operationList = (ops == null) ? List.of() : ops; + operationList = operationList.stream().filter(Objects::nonNull).toList(); + + // Convert the list to an ArrayNode by iteration, since valueToNode() can + // miss fields if the entire list is passed in. + ArrayNode array = JsonUtils.getJsonNodeFactory().arrayNode(); + for (PatchOperation operation : operationList) + { + array.add(JsonUtils.valueToNode(operation)); + } + + ObjectNode node = JsonUtils.getJsonNodeFactory().objectNode(); + return patch(endpoint, node.set("Operations", array)); + } + + /** + * Creates a bulk patch operation. The provided ObjectNode should be + * structured similarly to a {@link PatchRequest} JSON. An example is shown + * below: + *
+   *   {
+   *     "Operations": [
+   *       {
+   *         (patch operation 1),
+   *         (patch operation 2)
+   *       },
+   *       ...
+   *     ]
+   *   }
+   * 
+ * + * @param endpoint The endpoint/path that will receive the PATCH request, + * e.g., {@code /Users/b7c14771-226c-4d05-8860-134711653041}. + * @param data The ObjectNode representing the list of + * {@link PatchOperation} objects. + * + * @return The new bulk operation. + * @throws BulkRequestException If the ObjectNode improperly formatted. + */ + @NotNull + public static BulkOperation patch(@NotNull final String endpoint, + @NotNull final ObjectNode data) + throws BulkRequestException + { + return new BulkPatchOperation(endpoint, data.deepCopy()); + } + + /** + * Constructs a bulk DELETE operation. This represents an individual delete + * operation for a resource. The following example shows a JSON representation + * of a bulk DELETE operation: + *
+   *  {
+   *    "method": "DELETE",
+   *    "path": "/Users/b7c14771-226c-4d05-8860-134711653041"
+   *  }
+   * 
+ * + * @param endpoint The endpoint/path that will receive the DELETE request, + * e.g., {@code /Users/b7c14771-226c-4d05-8860-134711653041}. + * + * @return The new bulk operation. + */ + @NotNull + public static BulkOperation delete(@NotNull final String endpoint) + { + return new DeleteOperation(endpoint); + } + + /** + * When deserializing a JSON into this class, there's a possibility that an + * unknown attribute is contained within the JSON. This method captures + * attempts to set undefined attributes and ignores them in the interest of + * preventing JsonProcessingException errors. This method should only be + * called by Jackson. + * + * @param key The unknown attribute name. + * @param ignoredValue The value of the attribute. + */ + @JsonAnySetter + protected void setAny(@NotNull final String key, + @NotNull final JsonNode ignoredValue) + { + // The value is not logged, since it's not needed and may contain PII. + Debug.debug(Level.WARNING, DebugType.OTHER, + "Attempted setting an undefined attribute: " + key); + } + + /** + * Retrieves a string representation of this bulk operation. + * + * @return A string representation of this bulk operation. + */ + @Override + @NotNull + public String toString() + { + try + { + var objectWriter = JsonUtils.getObjectWriter(); + return objectWriter.withDefaultPrettyPrinter().writeValueAsString(this); + } + catch (JsonProcessingException e) + { + // This should never happen. + throw new RuntimeException(e); + } + } + + /** + * Indicates whether the provided object is equal to this bulk operation. + * + * @param o The object to compare. + * @return {@code true} if the provided object is equal to this bulk + * operation, or {@code false} if not. + */ + @Override + public boolean equals(@Nullable final Object o) + { + if (this == o) + { + return true; + } + if (!(o instanceof BulkOperation that)) + { + return false; + } + + if (!Objects.equals(method, that.method)) + { + return false; + } + if (!Objects.equals(path, that.path)) + { + return false; + } + if (!Objects.equals(bulkId, that.bulkId)) + { + return false; + } + if (!Objects.equals(version, that.version)) + { + return false; + } + return Objects.equals(data, that.data); + } + + /** + * Retrieves a hash code for this bulk operation. + * + * @return A hash code for this bulk operation. + */ + @Override + public int hashCode() + { + return Objects.hash(method, path, bulkId, version, data); + } + + /** + * Provides another instance of this bulk operation. + * + * @return A copy of this bulk operation. + */ + @NotNull + @SuppressWarnings("DataFlowIssue") + public BulkOperation copy() + { + return switch (method) + { + case POST -> post(path, data).setBulkId(bulkId); + case PUT -> put(path, data).setVersion(version); + case PATCH -> patch(path, data).setVersion(version); + case DELETE -> delete(path).setVersion(version); + }; + } } diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperationResult.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperationResult.java index 0cf72f45..5b1a512a 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperationResult.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperationResult.java @@ -32,6 +32,709 @@ package com.unboundid.scim2.common.bulk; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.unboundid.scim2.common.ScimResource; +import com.unboundid.scim2.common.annotations.NotNull; +import com.unboundid.scim2.common.annotations.Nullable; +import com.unboundid.scim2.common.exceptions.BulkResponseException; +import com.unboundid.scim2.common.exceptions.ScimException; +import com.unboundid.scim2.common.messages.ErrorResponse; +import com.unboundid.scim2.common.types.ETagConfig; +import com.unboundid.scim2.common.utils.BulkStatusDeserializer; +import com.unboundid.scim2.common.utils.Debug; +import com.unboundid.scim2.common.utils.DebugType; +import com.unboundid.scim2.common.utils.JsonUtils; + +import java.util.Objects; +import java.util.logging.Level; + + +/** + * This class represents an operation contained within a {@link BulkResponse}. + * For an introduction to SCIM bulk processing, see the documentation for + * {@link BulkRequest}. + *

+ * + * Both {@link BulkRequest} and {@link BulkResponse} objects contain an + * {@code Operations} array, with similar elements contained within them. + * However, there are specific restrictions on what fields may be defined for + * operations in a request compared to ones in a response. For example, client + * bulk POST requests should never have a {@code version} tag set, since a + * resource that does not exist cannot have a checksum, but a bulk POST response + * may have this field. To help create a distinction between these scenarios, + * operations within a bulk request are represented by {@link BulkOperation}, + * and operations within a bulk response are called "bulk operation results", + * represented by this class. + *

+ * + * Bulk operation results contain the following fields: + *
    + *
  • {@code location}: The location URI for the resource that was targeted. + * This field MUST be present, except in the case that a POST operation + * fails. Since a failure to create a resource means that a URI does not + * exist, this is the only case where the location will be {@code null}. + *
  • {@code method}: The HTTP method that was used for the bulk operation. + *
  • {@code response}: The JSON response body for the write request. If the + * response was successful, this may optionally be {@code null}, but it + * must be present for an error. Null values are permitted so that + * services can avoid sending payloads that exceed size limits. + *
  • {@code status}: The HTTP status code for the operation, e.g., + * {@code 200 OK}, {@code 404 NOT FOUND}, etc. + *
  • {@code bulkId}: The bulk ID that was provided in the POST request. + *
  • {@code version}: The {@link ETagConfig ETag} version of the resource. + *
+ *

+ * + * The following is an example of a bulk operation result that corresponds to + * a successful user create response: + *
+ * {
+ *   "location": "https://example.com/v2/Users/fa1afe1",
+ *   "method": "POST",
+ *   "bulkId": "originalBulkId",
+ *   "response": {
+ *     "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ],
+ *     "id": "fa1afe1",
+ *     "userName": "walker"
+ *   },
+ *   "status": "200"
+ * }
+ * 
+ * + * Bulk operation responses can be created with Java code of the following form. + *

+ *   BulkOperation sourceOp = getClientBulkOperation();
+ *   UserResource userResource = new UserResource().setUserName("walker");
+ *   user.setId("fa1afe1");
+ *   BulkOperationResult result = new BulkOperationResult(
+ *       sourceOp,
+ *       BulkOperationResult.HTTP_STATUS_CREATED,
+ *       "https://example.com/v2/Users/fa1afe1");
+ *   result.setResponse(userResource);
+ *
+ *   // If desired, the fields may be set directly.
+ *   BulkOperationResult result = new BulkOperationResult(
+ *       BulkOpType.POST,
+ *       BulkOperationResult.HTTP_STATUS_CREATED,
+ *       "https://example.com/v2/Users/fa1afe1",
+ *       userResource.asGenericScimResource().getObjectNode(),
+ *       "originalBulkId",
+ *       null);
+ * 
+ * + * Unlike {@link BulkOperation}, this {@link BulkOperationResult} does not use + * static helper methods since all result types are formatted the same and have + * similar restrictions. There are, however, alternate constructors available + * for error responses, which have a separate consistent format based on the + * {@link ErrorResponse} object that should be displayed. An example error is + * shown below: + *
+ * {
+ *   "method": "POST",
+ *   "response": {
+ *     "schemas": [ "urn:ietf:params:scim:api:messages:2.0:Error" ],
+ *     "scimType": "invalidSyntax",
+ *     "detail": "Request is unparsable or violates the schema.",
+ *     "status": "400"
+ *   },
+ *   "status": "400"
+ * }
+ * 
+ * + * Bulk response errors are required to be displayed in the {@code response} + * field so that clients can understand the cause of the failure. The following + * Java code may be used to construct bulk operation result errors: + *

+ *   // The location is always null for POST request failures.
+ *   BulkOperation source = getBulkOperation();
+ *   BadRequestException e = BadRequestException.invalidSyntax(
+ *       "Request is unparsable or violates the schema.");
+ *   BulkOperationResult postResult = new BulkOperationResult(e, source, null);
+ *
+ *   // Provide the location for all other operation types.
+ *   ForbiddenException fe = new ForbiddenException("User is unauthorized.");
+ *   String location = "https://example.com/v2/Users/fa1afe1";
+ *   BulkOperationResult result =
+ *       new BulkOperationResult(fe, BulkOpType.PUT, location);
+ * 
+ * + * @since 5.1.0 + */ +@SuppressWarnings("JavadocLinkAsPlainText") +@JsonPropertyOrder({ "location", "method", "bulkId", "version", "response", + "status" }) public class BulkOperationResult { + /** + * Represents the status code for {@code HTTP 200 OK}. + */ + @NotNull public static final String HTTP_STATUS_OK = "200"; + + /** + * Represents the status code for {@code HTTP 201 CREATED}. + */ + @NotNull public static final String HTTP_STATUS_CREATED = "201"; + + /** + * Represents the status code for {@code HTTP 204 NO_CONTENT}. + */ + @NotNull public static final String HTTP_STATUS_NO_CONTENT = "204"; + + /** + * The location of the resource referenced by this bulk operation result. This + * will only be {@code null} for client POST request failures, since there is + * not an existing resource to reference. + */ + @Nullable + private String location; + + /** + * This field represents the HTTP operation type (e.g., POST). + */ + @NotNull + private final BulkOpType method; + + /** + * The bulk ID of the original bulk operation. For more information, see + * {@link BulkOperation#bulkId}. + */ + @Nullable + private String bulkId; + + /** + * The optional version tag, which can be used if the SCIM service provider + * supports ETag versioning. See {@link ETagConfig} for more information. + */ + @Nullable + private String version; + + /** + * The JSON data representing the response for the individual write request. + * The form of this value depends on the nature of the bulk operation result: + *
    + *
  • For unsuccessful responses, this will return the error response, + * represented as an {@link ErrorResponse} object. + *
  • For successful {@code DELETE} operations, this will be {@code null}. + *
  • For successful responses, this may still be {@code null} since it is + * an optional field for services to populate. Otherwise, it will + * contain the updated resource. + *
+ */ + @Nullable + private ObjectNode response; + + /** + * The status code to return for the write request, e.g., {@code "200"} + * representing a {@code 200 OK} response for a successful modification. + */ + @NotNull + @JsonDeserialize(using = BulkStatusDeserializer.class) + private String status = ""; + + /** + * The status value as an integer. + */ + private int statusInt; + + /** + * Constructs an entry to be contained in a {@link BulkResponse} based on the + * initial bulk operation that requested the update. + *

+ * + * Since bulk IDs should only be used for POST requests, this constructor will + * only copy the bulk ID from the {@code operation} if it refers to a POST. + * + * @param operation The source bulk operation. + * @param status The HTTP response status code for the update, e.g., "200". + * @param location The location URI of the modified resource. This must not + * be {@code null}, except in the case of a POST failure. + * + * @throws BulkResponseException If the location is {@code null} for a + * response that is not a POST failure. + */ + public BulkOperationResult(@NotNull final BulkOperation operation, + @NotNull final String status, + @Nullable final String location) + throws BulkResponseException + { + // The location is never guaranteed to be present in the operation, either + // in the 'path' (which could be '/Users' for POST) or the 'data' (which + // may be null). Thus, we must explicitly request it as a parameter. + this(operation.getMethod(), + status, + location, + null, + operation.getMethod() == BulkOpType.POST ? operation.getBulkId() : null, + null); + } + + /** + * Constructs an entry to be contained in a {@link BulkResponse}. + * + * @param method The HTTP method (e.g., POST). + * @param status A string indicating the HTTP response code, e.g., "200". + * @param response The JSON data indicating the response for the operation. + * This may optionally be {@code null} if the status is a + * {@code 2xx} successful response. + * @param location The location URI of the modified resource. This must not + * be {@code null}, except in the case of a POST failure. + * @param bulkId The bulk ID specified by the client, if it exists. + * @param version The ETag version of the resource, if the service provider + * supports it. + * + * @throws BulkResponseException If the location is {@code null} for a + * response that is not a POST failure. + */ + @JsonCreator + public BulkOperationResult( + @NotNull @JsonProperty(value = "method") final BulkOpType method, + @NotNull @JsonProperty(value = "status") final String status, + @Nullable @JsonProperty(value = "location") final String location, + @Nullable @JsonProperty(value = "response") final ObjectNode response, + @Nullable @JsonProperty(value = "bulkId") final String bulkId, + @Nullable @JsonProperty(value = "version") final String version) + throws BulkResponseException + { + this.method = Objects.requireNonNull(method); + this.location = location; + this.response = response; + this.version = version; + setBulkId(bulkId); + setStatus(status); + + boolean isUnsuccessfulPost = + method == BulkOpType.POST && (isClientError() || isServerError()); + if (location == null && !isUnsuccessfulPost) + { + throw new BulkResponseException( + "The 'location' of a BulkOperationResult must be defined, with the" + + " exception of unsuccessful POST requests." + ); + } + } + + /** + * Constructs a bulk operation result that represents an error. See the + * class-level documentation for an example. + * + * @param scimException The SCIM error that will be displayed in the + * {@code response} field. + * @param operation The bulk operation that was attempted. + * @param location The URI of the resource targeted by the bulk + * operation. This field will be ignored for POST + * operations, as the resource will not exist. This + * value may only be {@code null} for failed POSTs. + */ + public BulkOperationResult(@NotNull final ScimException scimException, + @NotNull final BulkOperation operation, + @Nullable final String location) + { + this(scimException, operation.getMethod(), location); + + // Bulk IDs should only be handled for POST requests. + String bid = (method == BulkOpType.POST) ? operation.getBulkId() : null; + setBulkId(bid); + } + + /** + * Constructs a bulk operation result that represents an error. + * + * @param scimException The SCIM error that will be displayed in the + * {@code response} field. + * @param opType The type of the bulk operation. + * @param location The URI of the resource targeted by the bulk + * operation. This field will be ignored for POST + * operations, as the resource will not exist. This + * value may only be {@code null} for failed POSTs. + */ + public BulkOperationResult(@NotNull final ScimException scimException, + @NotNull final BulkOpType opType, + @Nullable final String location) + { + this(opType, + scimException.getScimError().getStatus().toString(), + (opType == BulkOpType.POST) ? null : location, + JsonUtils.valueToNode(scimException.getScimError()), + null, + null); + } + + /** + * Sets the {@code location} field for this bulk operation result. For + * example, for a bulk operation result that created a resource, the location + * could take the form of {@code https://example.com/v2/fa1afe1}. + *

+ * + * This may only be {@code null} for client POST request failures, since there + * is not an existing resource to reference. + * + * @param location The new value for the location. + * @return This bulk operation result. + */ + @NotNull + public BulkOperationResult setLocation(@Nullable final String location) + { + if (method != BulkOpType.POST) + { + Objects.requireNonNull(location); + } + + this.location = location; + return this; + } + + /** + * Sets the bulk ID of this bulk operation result. + * + * @param bulkId The bulk ID value. This BulkOperationResult's bulk ID should + * reflect the bulk ID that was present on the original client + * {@link BulkOperation}. + * @return This bulk operation result. + */ + @NotNull + public BulkOperationResult setBulkId(@Nullable final String bulkId) + { + this.bulkId = bulkId; + return this; + } + + /** + * Sets the ETag version of this bulk operation result. + * + * @param version The version tag to use. + * @return This bulk operation result. + */ + @NotNull + public BulkOperationResult setVersion(@Nullable final String version) + { + this.version = version; + return this; + } + + /** + * Sets the response of this bulk operation result. + * + * @param response The JSON payload representing the response to the + * individual client {@link BulkOperation}. + * @return This bulk operation result. + */ + @NotNull + @JsonSetter + public BulkOperationResult setResponse(@Nullable final ObjectNode response) + { + this.response = response; + return this; + } + + /** + * Sets the response of this bulk operation result. + * + * @param response The resource representing the JSON payload response to + * return to the client. + * @return This bulk operation result. + */ + @NotNull + public BulkOperationResult setResponse(@Nullable final ScimResource response) + { + ObjectNode node = (response == null) ? + null : response.asGenericScimResource().getObjectNode(); + return setResponse(node); + } + + /** + * Sets the HTTP status code representing the result after the client + * {@link BulkOperation} was processed. + * + * @param status The HTTP status code. + * @return This bulk operation result. + * + * @throws BulkResponseException If the provided value was not an integer. + */ + @NotNull + @JsonSetter + public BulkOperationResult setStatus(@NotNull final String status) + throws BulkResponseException + { + Objects.requireNonNull(status); + + try + { + Integer intValue = Integer.parseInt(status); + return setStatus(intValue); + } + catch (NumberFormatException e) + { + throw new BulkResponseException( + "Could not convert '" + status + "' to an integer HTTP status code."); + } + } + + /** + * Sets the HTTP status code representing the result code for the original + * client {@link BulkOperation}. + * + * @param status The HTTP status code. + * @return This bulk operation result. + */ + @NotNull + public BulkOperationResult setStatus(@NotNull final Integer status) + { + this.status = status.toString(); + statusInt = status; + return this; + } + + /** + * Retrieves the location of this bulk operation result. + * + * @return The bulk operation result location. + */ + @Nullable + public String getLocation() + { + return location; + } + + /** + * Fetches the method of this bulk operation result (e.g., {@code POST}). + * + * @return The bulk operation result method. + */ + @NotNull + public BulkOpType getMethod() + { + return method; + } + + /** + * Fetches the bulk ID of this bulk operation result. + * + * @return The bulk ID. + */ + @Nullable + public String getBulkId() + { + return bulkId; + } + + /** + * Fetches the ETag version of this bulk operation result. + * + * @return The bulk operation result version. + */ + @Nullable + public String getVersion() + { + return version; + } + + /** + * Fetches the response of this bulk operation result. This will be + * {@code null} for deletes, and may optionally be {@code null} for other + * successful responses, but will not be {@code null} for error responses. + * + * @return The HTTP response for the bulk operation result, or {@code null} + * if it is not available. + */ + @Nullable + public ObjectNode getResponse() + { + return response; + } + + /** + * Fetches the response of this BulkOperationResult as a {@link ScimResource} + * based POJO. See {@link BulkOperation#getDataAsScimResource()} for more + * information. Note that this method will never return a + * {@code PatchRequest}, unlike the variant in {@code BulkOperation}. + * + * @return The bulk operation result response, or {@code null} if there was + * no response. + * @throws BulkResponseException If an error occurs while converting the + * object into a ScimResource type. + */ + @Nullable + @JsonIgnore + public ScimResource getResponseAsScimResource() + throws BulkResponseException + { + try + { + return BulkResourceMapper.asScimResource(response); + } + catch (IllegalArgumentException e) + { + throw new BulkResponseException( + "A malformed JSON failed to be converted into a SCIM resource.", e); + } + } + + /** + * Indicates whether the {@code status} represents an HTTP 2xx successful + * response code. + * + * @return A boolean indicating a success. + */ + @JsonIgnore + public boolean isSuccess() + { + return statusInt >= 200 && statusInt < 300; + } + + /** + * Indicates whether the {@code status} represents an HTTP 4xx client error + * response code. + * + * @return A boolean indicating an error. + */ + @JsonIgnore + public boolean isClientError() + { + return statusInt >= 400 && statusInt < 500; + } + + /** + * Indicates whether the {@code status} represents an HTTP 5xx server error + * response code. + * + * @return A boolean indicating an error. + */ + @JsonIgnore + public boolean isServerError() + { + return statusInt >= 500 && statusInt < 600; + } + + /** + * Fetches the status of this bulk operation result. + * + * @return The bulk operation result status. + */ + @NotNull + public String getStatus() + { + return status; + } + + /** + * Fetches the numerical value of {@link #getStatus()}. + * + * @return The bulk operation result status as an integer. + */ + @JsonIgnore + public int getStatusInt() + { + return statusInt; + } + + /** + * When deserializing a JSON into this class, there's a possibility that an + * unknown attribute is contained within the JSON. This method captures + * attempts to set undefined attributes and ignores them in the interest of + * preventing JsonProcessingException errors. This method should only be + * called by Jackson. + * + * @param key The unknown attribute name. + * @param ignoredValue The value of the attribute. + */ + @JsonAnySetter + protected void setAny(@NotNull final String key, + @NotNull final JsonNode ignoredValue) + { + // The value is not logged, since it's not needed and may contain PII. + Debug.debug(Level.WARNING, DebugType.OTHER, + "Attempted setting an undefined attribute: " + key); + } + + @Override + @NotNull + public String toString() + { + try + { + var objectWriter = JsonUtils.getObjectWriter(); + return objectWriter.withDefaultPrettyPrinter().writeValueAsString(this); + } + catch (JsonProcessingException e) + { + // This should never happen. + throw new RuntimeException(e); + } + } + + /** + * Indicates whether the provided object is equal to this bulk operation + * result. + * + * @param o The object to compare. + * @return {@code true} if the provided object is equal to this bulk + * operation result, or {@code false} if not. + */ + @Override + public boolean equals(@Nullable final Object o) + { + if (this == o) + { + return true; + } + if (!(o instanceof BulkOperationResult that)) + { + return false; + } + + if (!Objects.equals(location, that.location)) + { + return false; + } + if (!method.equals(that.method)) + { + return false; + } + if (!Objects.equals(bulkId, that.bulkId)) + { + return false; + } + if (!Objects.equals(version, that.version)) + { + return false; + } + if (!status.equals(that.status)) + { + return false; + } + return Objects.equals(response, that.response); + } + + /** + * Retrieves a hash code for this bulk operation result. + * + * @return A hash code for this bulk operation result. + */ + @Override + public int hashCode() + { + return Objects.hash(location, method, bulkId, version, status, response); + } + + /** + * Provides another instance of this bulk operation result. + * + * @return A copy of this BulkOperationResult. + */ + @NotNull + public BulkOperationResult copy() + { + ObjectNode responseCopy = (response == null) ? null : response.deepCopy(); + return new BulkOperationResult( + method, status, location, responseCopy, bulkId, version); + } } diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkRequest.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkRequest.java index 8a3265da..7b94b8eb 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkRequest.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkRequest.java @@ -32,18 +32,486 @@ package com.unboundid.scim2.common.bulk; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; import com.unboundid.scim2.common.BaseScimResource; +import com.unboundid.scim2.common.ScimResource; +import com.unboundid.scim2.common.annotations.Attribute; import com.unboundid.scim2.common.annotations.NotNull; import com.unboundid.scim2.common.annotations.Nullable; +import com.unboundid.scim2.common.annotations.Schema; +import com.unboundid.scim2.common.exceptions.BulkRequestException; +import com.unboundid.scim2.common.exceptions.BulkResponseException; +import com.unboundid.scim2.common.exceptions.ContentTooLargeException; +import com.unboundid.scim2.common.exceptions.RateLimitException; +import com.unboundid.scim2.common.exceptions.ResourceConflictException; +import com.unboundid.scim2.common.messages.ListResponse; +import com.unboundid.scim2.common.types.ServiceProviderConfigResource; -import java.util.Collections; import java.util.Iterator; import java.util.List; +import java.util.Objects; +import static com.unboundid.scim2.common.utils.StaticUtils.toList; + +/** + * This class represents a SCIM 2 bulk request as described by + * + * RFC 7644 Section 3.7. + * + *

SCIM 2 Bulk Requests and Responses

+ * + * Issuing a REST API call always incurs request processing overhead, namely + * DNS resolution, TCP handshakes and termination, and TLS negotiation. This is + * necessary work for all API calls, but can become expensive in cases where a + * client anticipates sending large amounts of traffic (e.g., on the order of + * millions of requests). A SCIM bulk request is an optimization that combines + * multiple write requests into a single API call, minimizing the number of + * times that overhead costs are paid by both the client and server. The + * following documentation discusses some key points on utilizing this class + * to support bulk workflows. For further background, see + * + * RFC 7644. + *

+ * + * The SCIM standard's protocol for batching requests is centered on write + * traffic. Clients may issue a "bulk request" to a SCIM service, which + * represents a collection of write "operations". After the SCIM service has + * processed the request, it will respond with a {@link BulkResponse}, which + * contains a summary of the request processing that was performed by the + * service. Bulk requests contain a list of {@link BulkOperation} objects, each + * representing a write operation, and bulk responses contain a list of + * {@link BulkOperationResult} objects, each representing the result of a client + * operation that was processed. + *

+ * + * Note that HTTP infrastructure generally has limits on the size of request + * payloads, so clients should be careful to avoid placing too many operations + * in a bulk request, as it could exceed service bulk limits. To determine the + * limits and configuration of a SCIM service provider, consult the service's + * {@link ServiceProviderConfigResource /ServiceProviderConfig} endpoint, or + * consult the vendor's documentation. + *

+ * + * Bulk requests, which are represented by this class, contain the following + * fields: + *
    + *
  • {@code failOnErrors}: An optional integer that specifies a threshold + * for the amount of acceptable errors. If this field is not set, then + * the SCIM service will attempt to fulfill as many operations as it can. + * For more detail on how this is defined, see {@link #setFailOnErrors}. + *
  • {@code Operations}: A list of {@link BulkOperation} values, where each + * operation represents a single write request. + *
+ *

+ * + * The following JSON is an example of a bulk request. This request contains two + * bulk operations that each attempt to create a user. The {@code failOnErrors} + * parameter is not specified in this bulk request, so the SCIM service should + * attempt to process all provided bulk operations. + *
+ * {
+ *   "schemas": [ "urn:ietf:params:scim:api:messages:2.0:BulkRequest" ],
+ *   "Operations": [ {
+ *     "method": "POST",
+ *     "path": "/Users",
+ *     "data": {
+ *       "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ],
+ *       "userName": "Alice"
+ *     }
+ *   }, {
+ *     "method": "POST",
+ *     "path": "/Users",
+ *     "data": {
+ *       "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ],
+ *       "userName": "Whiplash!"
+ *     }
+ *   } ]
+ * }
+ * 
+ * + * After this request is processed by a SCIM service, it may return a bulk + * response such as the following: + *
+ * {
+ *   "schemas": [ "urn:ietf:params:scim:api:messages:2.0:BulkResponse" ],
+ *   "Operations": [ {
+ *     "method": "POST",
+ *     "response": {
+ *       "schemas": [ "urn:ietf:params:scim:api:messages:2.0:Error" ],
+ *       "scimType": "uniqueness",
+ *       "detail": "The requested username was not available.",
+ *       "status": "400"
+ *     },
+ *     "status": "400"
+ *   }, {
+ *     "location": "https://example.com/v2/Users/5b261bdf",
+ *     "method": "POST",
+ *     "response": {
+ *       "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ],
+ *       "userName": "Whiplash!"
+ *       "id": "5b261bdf",
+ *       "meta": {
+ *         "created": "1970-01-01T13:00:00Z"
+ *       },
+ *     },
+ *     "status": "201"
+ *   } ]
+ * }
+ * 
+ * + * In the above response, the SCIM service responded to each user request + * differently. For the first user, an error was returned since the service + * enforces uniqueness for usernames, and the requested username was already + * taken. The second user was processed successfully and returned a response + * that would be seen from a typical SCIM POST request to the {@code /Users} + * endpoint. Note that the SCIM standard states that the {@code response} field + * is always defined with information in the event of an error, but successful + * responses may omit the value to reduce the size of the response. + *

+ * + * To create the above bulk request and response, the following Java code may + * be used. + *

+ *   // Create the bulk request.
+ *   BulkRequest bulkRequest = new BulkRequest(
+ *     BulkOperation.post("/Users", new UserResource().setUserName("Alice")),
+ *     BulkOperation.post("/Users", new UserResource().setUserName("Whiplash!"))
+ *   );
+ *
+ *   // Create the bulk response.
+ *   //
+ *   // This example uses raw values to showcase the appropriate library calls.
+ *   // In production systems, metadata values are obtained from the data store.
+ *   BadRequestException e = BadRequestException.uniqueness(
+ *       "The requested username was not available.");
+ *   final BulkOperationResult failedResult =
+ *       new BulkOperationResult(e, BulkOpType.POST, null);
+ *
+ *   final BulkOperationResult successResult = new BulkOperationResult(
+ *       BulkOpType.POST,
+ *       BulkOperationResult.HTTP_STATUS_CREATED,
+ *       "https://example.com/v2/Users/5b261bdf");
+ *   // Set the JSON payload value.
+ *   UserResource user = new UserResource().setUserName("Whiplash!");
+ *   user.setId("5b261bdf");
+ *   user.setMeta(new Meta().setCreated(Calendar.getInstance()));
+ *   successResult.setResponse(user);
+ *
+ *   BulkResponse response = new BulkResponse(failedResult, successResult);
+ * 
+ * + *

Handling Bulk Request and Response Data

+ * + * Before discussing an example workflow, it's important to highlight how + * application code should manage JSON data contained within bulk requests and + * responses. As seen in the example JSON object above, SCIM resource objects + * are embedded within bulk requests (within the {@code data} field) and bulk + * responses (within the {@code response} field). However, the objects contained + * within these fields could be anything: they could be error responses, user + * resources, group resources, or custom resource types defined by the service. + * Since the objects returned are not guaranteed to be the same type, this class + * is not typed like {@link ListResponse}. Instead, the UnboundID SCIM SDK + * provides two helper methods to simplify conversion of any SCIM resource that + * is placed within this field. This manages the conversion of JSON data into + * Java POJOs by viewing the {@code schemas} attribute. These are useful whether + * you are handling a bulk request or response: + *
    + *
  • {@link BulkOperation#getDataAsScimResource()} + *
  • {@link BulkOperationResult#getResponseAsScimResource()} + *
+ *

+ * + * With these methods, along with the {@code instanceof} keyword, application + * code can easily and directly handle object representations of SCIM resources. + * As an example, a service handling client bulk requests may use the following + * structure. This example does not handle client bulk ID values (see + * {@link BulkOperation}), and it is intentionally verbose to indicate cases that + * can occur for all types of requests and responses. + *

+ *   public BulkResponse processBulkRequest(BulkRequest bulkRequest)
+ *   {
+ *     int errorCount = 0;
+ *     List<BulkOperationResult> results = new ArrayList<>();
+ *     for (BulkOperation op : bulkRequest)
+ *     {
+ *       BulkOperationResult result = switch (op.getMethod())
+ *       {
+ *         case POST -> processPostOperation(op);
+ *         // ...
+ *       };
+ *
+ *       // Add the result to the list.
+ *       results.add(result);
+ *
+ *       // Track whether this result exceeded the failure threshold.
+ *       if (result.isClientError() || result.isServerError())
+ *       {
+ *         errorCount++;
+ *       }
+ *       if (errorCount >= bulkRequest.getFailOnErrorsNormalized())
+ *       {
+ *         // Stop processing since the error threshold was reached.
+ *         break;
+ *       }
+ *     }
+ *
+ *     // Processing completed. Return the result.
+ *     return new BulkResponse(results);
+ *   }
+ *
+ *   private BulkOperationResult processPostOperation(BulkOperation op)
+ *   {
+ *     ScimResource data = op.getDataAsScimResource();
+ *     if (data == null)
+ *     {
+ *       // POST requests should always have a JSON body.
+ *       throw new RuntimeException();
+ *     }
+ *     if (data instanceof UserResource user)
+ *     {
+ *       try
+ *       {
+ *         UserResource createdUser = validateAndCreateUser(user);
+ *         String location = createdUser.getMeta().getLocation().toString();
+ *         return new BulkOperationResult(op, HTTP_STATUS_CREATED, location);
+ *       }
+ *       catch (ScimException e)
+ *       {
+ *         // Locations are always null for POST failures.
+ *         String location = null;
+ *         return new BulkOperationResult(e, op, location);
+ *       }
+ *     }
+ *     else if (data instanceof GroupResource group)
+ *     {
+ *       // Group creation logic.
+ *     }
+ *     else if (data instanceof PatchRequest)
+ *     {
+ *       // PATCH requests should only be used for bulk patch operations, not
+ *       // for a POST.
+ *       throw new RuntimeException();
+ *     }
+ *     else if (data instanceof ErrorResponse)
+ *     {
+ *       // Error responses may be present within bulk responses, but not client
+ *       // bulk requests.
+ *       throw new RuntimeException();
+ *     }
+ *     else if (data instanceof GenericScimResource)
+ *     {
+ *       // The data did not match an expected object model, so it is invalid.
+ *       throw new RuntimeException();
+ *     }
+ *     else
+ *     {
+ *       // Other object types are not expected.
+ *       throw new RuntimeException();
+ *     }
+ *   }
+ * 
+ * + * The {@link BulkResourceMapper} is responsible for managing the conversions to + * {@link ScimResource} objects. See the mapper and + * {@link BulkOperation#getDataAsScimResource()} for more details. + *

+ * + *

Bulk IDs

+ * When regular, non-bulk SCIM requests are processed, there are sometimes some + * dependencies between requests. For example, a client may create a user, then + * create a group that contains that user. It is still possible to batch this + * workflow together into a single bulk request by leveraging something called + * bulk IDs. + *

+ * + * Bulk IDs grant a temporary identifier that may be added to POST operations + * within a bulk request, since those resources don't yet exist. Creating a user + * and placing it in a newly-created group can be accomplished with: + *
+ * {
+ *   "schemas": [ "urn:ietf:params:scim:api:messages:2.0:BulkRequest" ],
+ *   "Operations": [ {
+ *       "method": "POST",
+ *       "path": "/Users",
+ *       "bulkId": "hallmarkCards",
+ *       "data": {
+ *         "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ],
+ *         "userName": "Hallmarks"
+ *       }
+ *     }, {
+ *       "method": "POST",
+ *       "path": "/Groups",
+ *       "data": {
+ *         "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:Group" ],
+ *         "displayName": "Bodega",
+ *         "members": [ {
+ *           "type": "User",
+ *           "value": "bulkId:hallmarkCards"
+ *         } ]
+ *       }
+ *   } ]
+ * }
+ * 
+ * + * The first operation creates a user and denotes it with a bulk ID of + * "hallmarkCards". The second operation creates a group and adds the user as a + * member, which is done by referencing its bulk ID. This enables clients to + * batch more requests together, though it creates coupling between operations + * that SCIM services need to consider for bulk workflows. + *

+ * + * For SCIM service applications, the {@link #replaceBulkIdValues} method may be + * used to help with this processing. After an operation (e.g., creating a user) + * has completed successfully, all other operations that reference the bulk ID + * can be updated to start using the real {@code id} of the resource once it is + * available. Note that this directly modifies the operations contained within + * the bulk request to avoid repeatedly duplicating bulk objects in memory, and + * this method is not thread safe. + *

+ *   BulkOperation op;
+ *   BulkOperationResult result;
+ *   ScimResource createdResource;
+ *
+ *   String bulkId = op.getBulkId();
+ *   if (result.isSuccess() && bulkId != null)
+ *   {
+ *     // This may be called while iterating over the 'bulkRequest' object.
+ *     bulkRequest.replaceBulkIdValues(bulkId, createdResource.getId());
+ *   }
+ * 
+ * + * One complication with bulk IDs is that it can be possible to create circular + * dependencies. If a circular reference is contained within a request via + * {@code bulkId} fields of the {@link BulkOperation} objects, the service + * provider MUST try to resolve it, but MAY stop at a failed attempt with a + * {@link ResourceConflictException}. A simple example of + * a circular bulk request can be seen in + * + * RFC 7644 Section 3.7.1. Note that if a SCIM service allows creating loops + * in the graph representation of their groups, the linked example can be + * considered a valid circular dependency. + * + *

More About Bulk Requests

+ * + * Some additional background about bulk requests and how SCIM services may + * process them are shared below. + *
    + *
  • Bulk responses can return fewer results than what the client bulk + * request contained if the {@code failOnErrors} threshold was reached. + *
  • If a JSON fails to deserialize into a bulk request or bulk response + * object, it will throw a runtime {@link BulkRequestException} or + * {@link BulkResponseException} to indicate a malformed bulk object. + * Spring applications may leverage these in a controller advice. + *
  • Bulk operations within a request are not guaranteed to be processed + * in order. Specifically, RFC 7644 states that "[a] SCIM service + * provider MAY elect to optimize the sequence of operations received + * (e.g., to improve processing performance). When doing so, the + * service provider MUST ensure that the client’s intent is preserved + * and the same stateful result is achieved..." + *
  • By default, RFC 7644 states that SCIM services "MUST continue + * performing as many changes as possible and disregard partial + * failures". In other words, bulk requests must attempt to process as + * many of the bulk operations as possible, even if failures occur for + * some of the bulk operations. As stated above, clients may override + * this behavior by setting the {@code failOnErrors} field. + *
  • Bulk requests are not atomic. Successful updates will always be + * applied until the optional {@code failOnErrors} threshold is reached. + *
  • By their nature, bulk requests can quickly become expensive traffic + * for a SCIM service to process. As a client, it is important to be + * careful about restrictions such as payload sizes and rate limits when + * sending bulk requests to a SCIM service. Be prepared to handle + * {@link ContentTooLargeException} and {@link RateLimitException} errors. + *
  • As a SCIM service, it is generally a good idea to implement request + * limiting on endpoints that facilitate bulk request processing, as + * these endpoints can be a potential attack vector. This is crucial for + * cloud-based services, but is not as relevant for databases that expose + * a SCIM interface. + *
  • Consider memory usage when implementing bulk request support with this + * library. Large bulk payloads can result in high memory usage when JSON + * data is parsed into Jackson's tree model, especially in containerized + * environments that have small heap sizes. Since requests are likely + * processed with many threads, this could result in memory pressure + * if limits are not placed on JSON payload sizes or the bulk endpoints. + * Adding request limiting can be an effective defense against issues + * such as OutOfMemory errors and continuous garbage collection. + *
+ * + * For more details on the types of updates that can be issued within a bulk + * request, see {@link BulkOperation}. + * + * @since 5.1.0 + */ +@Schema(id = "urn:ietf:params:scim:api:messages:2.0:BulkRequest", + name = "Bulk Request", description = "SCIM 2.0 Bulk Request") public class BulkRequest extends BaseScimResource implements Iterable { + /** + * An integer specifying the number of errors that the SCIM service provider + * should accept before the operation is terminated with an error response. + * See {@link #setFailOnErrors} for more information. + *

+ * + * By default, this value is {@code null}, indicating that the SCIM service + * should attempt to process as many of the bulk operations as it can. + */ + @Nullable + @Attribute(description = """ + An integer specifying the number of errors that the service \ + provider will accept before the operation is terminated and an \ + error response is returned.""") + private Integer failOnErrors; + + @NotNull + @Attribute(description = """ + Defines operations within a bulk job. Each operation corresponds to a \ + single HTTP request against a resource endpoint.""", + isRequired = true, + multiValueClass = BulkOperation.class) + private final List operations; + + /** + * Creates a bulk request. + *

+ * + * Note that while the arguments to this method may each be {@code null}, they + * cannot both be {@code null}. This behavior ensures that invalid JSON data + * cannot be set to a BulkRequest during deserialization. If you wish to + * create an empty bulk request, use {@link BulkRequest#BulkRequest(List)}. + * + * @param failOnErrors The failure threshold as defined by + * {@link #setFailOnErrors(Integer)}. + * @param ops The list of {@link BulkOperation} objects. Any + * {@code null} elements will be ignored. + * + * @throws BulkRequestException If the bulk request contains no data. This is + * most likely due to an invalid JSON being + * converted to a bulk request. + */ + @JsonCreator + public BulkRequest( + @Nullable @JsonProperty(value="failOnErrors") final Integer failOnErrors, + @Nullable @JsonProperty(value="Operations") final List ops) + throws BulkRequestException + { + if (failOnErrors == null && ops == null) + { + // This state most likely comes from a source JSON for a completely + // different resource type. Thus, the failure here must not be silent. + throw new BulkRequestException( + "Could not create the bulk request since it would have been empty."); + } + + this.failOnErrors = failOnErrors; + + // Add the contents to an unmodifiable list. + List opsList = (ops == null) ? List.of() : ops; + operations = opsList.stream().filter(Objects::nonNull).toList(); + } + /** * Creates a bulk request. * @@ -52,6 +520,119 @@ public class BulkRequest extends BaseScimResource */ public BulkRequest(@Nullable final List operations) { + this(null, Objects.requireNonNullElse(operations, List.of())); + } + + /** + * Alternate constructor that allows specifying bulk operations individually. + * The optional {@code failOnErrors} field will not be set, but a value can be + * specified with the {@link #setFailOnErrors} method. + * + * @param operation The first bulk operation. This must not be {@code null}. + * @param operations An optional field for additional bulk operations. Any + * {@code null} values will be ignored. + */ + public BulkRequest(@NotNull final BulkOperation operation, + @Nullable final BulkOperation... operations) + { + this(toList(operation, operations)); + } + + /** + * Sets the failure threshold for the bulk request. If the value is + * {@code null}, the SCIM service provider will attempt to process all + * operations. Any value less than {@code 1} is not permitted. + *

+ * + * In general, this value refers to the number of errors that will cause a + * failed response. For example, if the value is {@code 1}, then any bulk + * operation that results in a failure will cause the SCIM service to stop and + * avoid attempting any of the remaining bulk operations present within the + * client bulk request. However, it is best to consult with the documentation + * for your SCIM service to be certain of the expected behavior, as some SCIM + * services may treat this value differently. + * + * @param value The failure count to specify. The value must not be less than + * 1, and {@code null} values will clear the field from the bulk + * request, which indicates that the SCIM service should + * attempt all provided operations. If the failure count exceeds + * this value, the request should return a + * {@link ResourceConflictException} (HTTP 409) status code. + * + * @return This object. + * @throws BulkRequestException If the provided value is less than 1. + */ + @NotNull + public BulkRequest setFailOnErrors(@Nullable final Integer value) + throws BulkRequestException + { + if (value != null && value < 1) + { + throw new BulkRequestException( + "The failOnErrors value (" + value + ") may not be less than 1."); + } + this.failOnErrors = value; + return this; + } + + /** + * Retrieves the value indicating the threshold for failing a bulk request. + * See {@link #setFailOnErrors} for more information. + * + * @return The value of {@code failOnErrors}. + */ + @Nullable + public Integer getFailOnErrors() + { + return failOnErrors; + } + + /** + * Retrieves the value indicating the threshold for failing a bulk request. + * This value will always be non-null so that SCIM services can easily + * evaluate thresholds without needing to handle {@code null}. See + * {@link #setFailOnErrors} for more information. + * + * @return The value of {@code failOnErrors}. + */ + @JsonIgnore + public int getFailOnErrorsNormalized() + { + return (failOnErrors == null) ? Integer.MAX_VALUE : failOnErrors; + } + + /** + * Retrieves an immutable list of the operations in this bulk request. + * + * @return The Operations list. + */ + @NotNull + @JsonProperty("Operations") + public List getOperations() + { + return operations; + } + + /** + * Replaces any temporary bulk ID values stored within the bulk request with + * the appropriate new value. This specifically targets the {@code data} field + * of each bulk operation, and does not affect the order of the operations in + * the list. See the class-level Javadoc for more information. + *

+ * + * This method permanently alters the bulk request directly, and it is not + * thread safe. + * + * @param bulkId The temporary bulk ID value. + * @param realValue The real value after the resource has been created. + */ + public void replaceBulkIdValues(@NotNull final String bulkId, + @NotNull final String realValue) + { + for (BulkOperation op : operations) + { + op.replaceBulkIdValue(bulkId, realValue); + } } /** @@ -61,6 +642,43 @@ public BulkRequest(@Nullable final List operations) @NotNull public Iterator iterator() { - return Collections.emptyIterator(); + return getOperations().iterator(); + } + + /** + * Indicates whether the provided object is equal to this bulk request. + * + * @param o The object to compare. + * @return {@code true} if the provided object is equal to this bulk + * request, or {@code false} if not. + */ + @Override + public boolean equals(@Nullable final Object o) + { + if (this == o) + { + return true; + } + if (!(o instanceof BulkRequest that)) + { + return false; + } + + if (!Objects.equals(failOnErrors, that.failOnErrors)) + { + return false; + } + return operations.equals(that.getOperations()); + } + + /** + * Retrieves a hash code for this bulk request. + * + * @return A hash code for this bulk request. + */ + @Override + public int hashCode() + { + return Objects.hash(failOnErrors, operations); } } diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkResponse.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkResponse.java index f334c147..72d2ebb8 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkResponse.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkResponse.java @@ -32,16 +32,157 @@ package com.unboundid.scim2.common.bulk; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; import com.unboundid.scim2.common.BaseScimResource; +import com.unboundid.scim2.common.annotations.Attribute; import com.unboundid.scim2.common.annotations.NotNull; +import com.unboundid.scim2.common.annotations.Nullable; +import com.unboundid.scim2.common.annotations.Schema; -import java.util.Collections; import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import static com.unboundid.scim2.common.utils.StaticUtils.toList; + +/** + * This class represents a SCIM 2 bulk response. This is an API response + * returned by a SCIM service after it has processed a bulk request, and it + * represents a summary of all bulk operations that were processed by the SCIM + * service. For an introduction to SCIM bulk processing, see the documentation + * for {@link BulkRequest}. + *

+ * + * The only field contained within a bulk response is {@code Operations}. Each + * individual object within the {@code Operations} array of a bulk response is + * represented by the {@link BulkOperationResult} class. + *

+ * + * An example bulk response is shown below: + *
+ *   {
+ *     "schemas": [ "urn:ietf:params:scim:api:messages:2.0:BulkResponse" ],
+ *     "Operations": [
+ *       {
+ *         "location": "https://example.com/v2/Users/fa1afe1",
+ *         "method": "POST",
+ *         "bulkId": "qwerty",
+ *         "response": {
+ *           "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ],
+ *           "userName": "Alice"
+ *         }
+ *         "status": "201"
+ *       }
+ *     ]
+ *   }
+ * 
+ * + * The bulk response indicates that the server-side processing successfully + * created one user resource. It also details the URI for the new resource, + * along with the original HTTP method, the bulk ID set in the client bulk + * request, the optional JSON response data, and the HTTP response status code. + *

+ * + * This bulk response can be created with the following Java code. See + * {@link BulkOperationResult} for additional ways to create individual results. + *

+ *   BulkOperationResult result = new BulkOperationResult(
+ *     BulkOpType.POST,
+ *     BulkOperationResult.HTTP_STATUS_CREATED,
+ *     "https://example.com/v2/Users/fa1afe1",
+ *     null,
+ *     null,
+ *     "W/\"4weymrEsh5O6cAEK\"");
+ *
+ *   result.setResponse(new UserResource().setUserName("Alice"));
+ *   BulkResponse response = new BulkResponse(result);
+ * 
+ * + * Bulk responses are most commonly constructed while iterating over a client + * bulk request. For more examples showcasing this, see {@link BulkRequest}. + *

+ * + * Like BulkRequest, BulkResponse objects are iterable, so it is possible for + * SCIM clients to iterate over the response object directly to validate the + * response from the server. The following code illustrates an example of a + * client handling a bulk response to track statistics: + *

+ *   public void process()
+ *   {
+ *     BulkResponse response = sendBulkRequestAndFetchResponse();
+ *     for (BulkOperationResult result : response)
+ *     {
+ *       if (result.isClientError() || result.isServerError())
+ *       {
+ *         logFailure(result);
+ *       }
+ *     }
+ *   }
+ * 
+ * + * @since 5.1.0 + */ +@SuppressWarnings("JavadocLinkAsPlainText") +@Schema(id = "urn:ietf:params:scim:api:messages:2.0:BulkResponse", + name = "Bulk Response", description = "SCIM 2.0 Bulk Response") public class BulkResponse extends BaseScimResource implements Iterable { + @NotNull + @Attribute(description = """ + Defines operations within a bulk job. Each operation corresponds to a \ + single HTTP request against a resource endpoint.""", + isRequired = true, + multiValueClass = BulkOperationResult.class) + @JsonProperty(value = "Operations", required = true) + private final List operations; + + + /** + * Creates a bulk response that summarizes the results of a client bulk + * request. + * + * @param results The list of bulk operation results to display in the bulk + * response. + */ + @JsonCreator + public BulkResponse( + @Nullable @JsonProperty(value = "Operations") + final List results) + { + List ops = (results == null) ? List.of() : results; + operations = ops.stream().filter(Objects::nonNull).toList(); + } + + /** + * Creates a bulk response that summarizes the results of a client bulk + * request. + * + * @param result The initial bulk operation result to include in the bulk + * response. + * @param results An optional field for additional bulk operation results. + * Any {@code null} values will be ignored. + */ + public BulkResponse(@NotNull final BulkOperationResult result, + @Nullable final BulkOperationResult... results) + { + this(toList(result, results)); + } + + /** + * Returns an immutable list of result objects contained within this bulk + * response. + * + * @return The Operations list. + */ + @NotNull + public List getOperations() + { + return operations; + } + /** * {@inheritDoc} */ @@ -49,6 +190,39 @@ public class BulkResponse extends BaseScimResource @NotNull public Iterator iterator() { - return Collections.emptyIterator(); + return getOperations().iterator(); + } + + /** + * Indicates whether the provided object is equal to this bulk response. + * + * @param o The object to compare. + * @return {@code true} if the provided object is equal to this bulk + * response, or {@code false} if not. + */ + @Override + public boolean equals(@Nullable final Object o) + { + if (this == o) + { + return true; + } + if (!(o instanceof BulkResponse that)) + { + return false; + } + + return operations.equals(that.operations); + } + + /** + * Retrieves a hash code for this bulk response. + * + * @return A hash code for this bulk response. + */ + @Override + public int hashCode() + { + return Objects.hash(operations); } } diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/BulkRequestException.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/BulkRequestException.java new file mode 100644 index 00000000..9396fdee --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/BulkRequestException.java @@ -0,0 +1,82 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.common.exceptions; + +import com.unboundid.scim2.common.annotations.NotNull; +import com.unboundid.scim2.common.annotations.Nullable; +import com.unboundid.scim2.common.bulk.BulkRequest; + + +/** + * This class represents a SCIM exception pertaining to an invalid state for + * bulk requests. This exception indicates that client-provided data in a bulk + * request would have resulted in an invalid object, and is most likely to occur + * when deserializing a bulk request JSON into a {@link BulkRequest}. This + * exception type should generally be caught and re-thrown as a + * {@link BadRequestException} to indicate invalid data from a client request. + *

+ * + * This is defined as a runtime exception to avoid unnecessary try-catch blocks + * for situations that are expected to be uncommon edge cases. This custom + * exception type is defined so that applications may easily catch these + * specific errors, such as in a Spring controller advice. + * + * @see BulkResponseException + */ +public class BulkRequestException extends RuntimeException +{ + /** + * Constructs a bulk request exception. + * + * @param message The error message for this SCIM bulk exception. + */ + public BulkRequestException(@NotNull final String message) + { + super(message); + } + + /** + * Constructs a bulk request exception. + * + * @param message The error message for this SCIM bulk exception. + * @param cause The cause (which is saved for later retrieval by the + * {@link #getCause()} method). A {@code null} value is + * permitted, and indicates that the cause is nonexistent or + * unknown. + */ + public BulkRequestException(@NotNull final String message, + @Nullable final Throwable cause) + { + super(message, cause); + } +} diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/BulkResponseException.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/BulkResponseException.java new file mode 100644 index 00000000..2109bfda --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/BulkResponseException.java @@ -0,0 +1,77 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.common.exceptions; + +import com.unboundid.scim2.common.annotations.NotNull; +import com.unboundid.scim2.common.annotations.Nullable; +import com.unboundid.scim2.common.bulk.BulkResponse; + + +/** + * This class represents a SCIM exception pertaining to an invalid state for + * bulk responses. Specifically, this exception indicates that server-provided + * data in a bulk response would have resulted in an invalid bulk response + * object, and is most likely to occur when deserializing a bulk response JSON + * into a {@link BulkResponse}. This exception type should generally be caught + * and re-thrown as a {@link ServerErrorException} to indicate an unexpected + * malformed response from a server. + * + * @see BulkRequestException + */ +public class BulkResponseException extends RuntimeException +{ + /** + * Constructs a bulk request exception. + * + * @param message The error message for this SCIM bulk exception. + */ + public BulkResponseException(@NotNull final String message) + { + super(message); + } + + /** + * Constructs a bulk request exception. + * + * @param message The error message for this SCIM bulk exception. + * @param cause The cause (which is saved for later retrieval by the + * {@link #getCause()} method). A {@code null} value is + * permitted, and indicates that the cause is nonexistent or + * unknown. + */ + public BulkResponseException(@NotNull final String message, + @Nullable final Throwable cause) + { + super(message, cause); + } +} diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/JsonUtils.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/JsonUtils.java index 5edfe7e3..22390753 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/JsonUtils.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/JsonUtils.java @@ -41,6 +41,7 @@ import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.NullNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; import com.fasterxml.jackson.databind.type.CollectionType; import com.unboundid.scim2.common.Path; import com.unboundid.scim2.common.annotations.NotNull; @@ -1045,6 +1046,61 @@ else if (child instanceof ObjectNode childObject) } } + /** + * Recursively traverses a JsonNode and replaces all text values with the + * appropriate new value. + * + * @param node The JsonNode that should be traversed. + * @param original The original string value contained within the JsonNode. + * @param newValue The new value to use as the replacement. + */ + public static void replaceAllTextValues(@NotNull final JsonNode node, + @NotNull final String original, + @NotNull final String newValue) + { + if (node instanceof ObjectNode objectNode) + { + for (Map.Entry field : objectNode.properties()) + { + JsonNode valueNode = field.getValue(); + if (valueNode.isTextual()) + { + String text = valueNode.asText(); + if (text.contains(original)) + { + // Replace the text node with the updated value + objectNode.put(field.getKey(), text.replace(original, newValue)); + } + } + else if (valueNode.isObject() || valueNode.isArray()) + { + // Recursively resolve nested values. + replaceAllTextValues(valueNode, original, newValue); + } + } + } + else if (node instanceof ArrayNode array) + { + for (int i = 0; i < array.size(); i++) + { + JsonNode element = array.get(i); + if (element.isTextual()) + { + String text = element.asText(); + if (text.contains(original)) + { + array.set(i, TextNode.valueOf(text.replace(original, newValue))); + } + } + else if (element.isObject() || element.isArray()) + { + // Recursively resolve nested values. + replaceAllTextValues(element, original, newValue); + } + } + } + } + /** * Factory method for constructing a SCIM compatible Jackson * {@link ObjectReader} with default settings. Note that the resulting diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkOperationResultTest.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkOperationResultTest.java new file mode 100644 index 00000000..4ea10bde --- /dev/null +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkOperationResultTest.java @@ -0,0 +1,584 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.common.bulk; + +import com.fasterxml.jackson.core.JacksonException; +import com.fasterxml.jackson.databind.ObjectReader; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import com.unboundid.scim2.common.GenericScimResource; +import com.unboundid.scim2.common.exceptions.BulkResponseException; +import com.unboundid.scim2.common.exceptions.ForbiddenException; +import com.unboundid.scim2.common.exceptions.RateLimitException; +import com.unboundid.scim2.common.exceptions.ResourceNotFoundException; +import com.unboundid.scim2.common.exceptions.ServerErrorException; +import com.unboundid.scim2.common.messages.ErrorResponse; +import com.unboundid.scim2.common.messages.PatchOperation; +import com.unboundid.scim2.common.types.GroupResource; +import com.unboundid.scim2.common.types.UserResource; +import com.unboundid.scim2.common.utils.JsonUtils; +import org.testng.annotations.Test; + +import java.util.List; +import java.util.UUID; + +import static com.unboundid.scim2.common.bulk.BulkOpType.*; +import static com.unboundid.scim2.common.bulk.BulkOperationResult.HTTP_STATUS_CREATED; +import static com.unboundid.scim2.common.bulk.BulkOperationResult.HTTP_STATUS_NO_CONTENT; +import static com.unboundid.scim2.common.bulk.BulkOperationResult.HTTP_STATUS_OK; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/** + * Tests for {@link BulkOperationResult}. + */ +public class BulkOperationResultTest +{ + // An example location URI for test use. + private static final String location = + "https://example.com/v2/Users/exampleResourceID"; + + /** + * Ensure {@link BulkOperationResult} objects can be successfully created from + * JSON strings. + */ + @Test + public void testDeserialization() throws Exception + { + final ObjectReader reader = JsonUtils.getObjectReader() + .forType(BulkOperationResult.class); + + String postJson = """ + { + "location": "https://example.com/v2/Users/92b...87a", + "method": "POST", + "bulkId": "qwerty", + "version": "W/\\"4weymrEsh5O6cAEK\\"", + "status": "201" + }"""; + BulkOperationResult result = reader.readValue(postJson); + assertThat(result.getLocation()) + .isEqualTo("https://example.com/v2/Users/92b...87a"); + assertThat(result.getMethod()).isEqualTo(POST); + assertThat(result.getBulkId()).isEqualTo("qwerty"); + assertThat(result.getVersion()).contains("4weymrEsh5O6cAEK"); + assertThat(result.getResponse()).isNull(); + assertThat(result.getResponseAsScimResource()).isNull(); + assertThat(result.getStatus()).isEqualTo("201"); + + // The associated "statusInt" value should not be printed in the JSON. + assertThat(result.toString()).doesNotContain("statusInt"); + + // Construct the same result via code and ensure it serializes into the same + // JSON. + BulkOperationResult created = new BulkOperationResult( + POST, + HTTP_STATUS_CREATED, + "https://example.com/v2/Users/92b...87a", + null, + "qwerty", + "W/\"4weymrEsh5O6cAEK\"" + ); + String reformattedJSON = reader.readTree(postJson).toPrettyString(); + assertThat(created.toString()).isEqualTo(reformattedJSON); + + // Test a JSON with a "status.code" field. This appears in the RFC in one + // location, so the SCIM SDK should parse this gracefully in case a client + // uses it. + String nestedStatusJson = """ + { + "location": "https://example.com/v2/Users/92b...87a", + "method": "PUT", + "status": { + "code": "200" + } + }"""; + result = reader.readValue(nestedStatusJson); + assertThat(result.getStatus()).isEqualTo("200"); + assertThat(result.getMethod()).isEqualTo(PUT); + assertThat(result.getLocation()) + .isEqualTo("https://example.com/v2/Users/92b...87a"); + assertThat(result.getBulkId()).isNull(); + assertThat(result.getVersion()).isNull(); + assertThat(result.getResponse()).isNull(); + assertThat(result.getResponseAsScimResource()).isNull(); + + // Other forms for "status" should not be permitted. + String invalidStatusJson = """ + { + "location": "https://example.com/v2/Users/92b...87a", + "method": "POST", + "status": { + "customCode": "201" + }"""; + assertThatThrownBy(() -> reader.readValue(invalidStatusJson)) + .isInstanceOf(JacksonException.class) + .hasMessageContaining("Could not parse the 'status' field"); + + // The "status" field must be a numeric value. + String notANumber = """ + { + "location": "https://example.com/v2/Users/92b...87a", + "method": "PUT", + "status": "OK" + }"""; + assertThatThrownBy(() -> reader.readValue(notANumber)) + .isInstanceOf(JacksonException.class) + .hasMessageContaining("Could not convert 'OK' to an integer"); + + String notANumber2 = """ + { + "location": "https://example.com/v2/Users/92b...87a", + "method": "PUT", + "status": { + "code": "OK" + } + }"""; + assertThatThrownBy(() -> reader.readValue(notANumber2)) + .isInstanceOf(JacksonException.class) + .hasMessageContaining("Could not convert 'OK' to an integer"); + + // Test the case where unknown properties are present in the JSON. This + // should not cause the request to fail. + String unknownPropertyJson = """ + { + "location": "https://example.com/v2/Users/92b...87a", + "method": "DELETE", + "status": "204", + "path": "pathIsOnlyDefinedForBulkOperations", + "unknownNonStandardField": "shouldNotCauseException" + }"""; + result = reader.readValue(unknownPropertyJson); + assertThat(result.getMethod()).isEqualTo(DELETE); + assertThat(result.getLocation()) + .isEqualTo("https://example.com/v2/Users/92b...87a"); + assertThat(result.getBulkId()).isNull(); + assertThat(result.getVersion()).isNull(); + assertThat(result.getResponse()).isNull(); + assertThat(result.getResponseAsScimResource()).isNull(); + assertThat(result.getStatus()).isEqualTo("204"); + } + + /** + * Validate construction of new bulk operation results. + */ + @Test + public void testConstructors() throws Exception + { + // Create a BulkOperationResult from the contents of a bulk operation. + BulkOperation sourceOp = BulkOperation.post("/Users", new UserResource()) + .setBulkId("qwerty"); + BulkOperationResult result = new BulkOperationResult(sourceOp, + "200", + "https://example.com/v2/Users/" + UUID.randomUUID() + ); + + // Validate the fields. + assertThat(result.getMethod()).isEqualTo(POST); + assertThat(result.getStatus()).isEqualTo("200"); + assertThat(result.isSuccess()).isTrue(); + assertThat(result.getLocation()).startsWith("https://example.com/v2/Users"); + assertThat(result.getResponse()).isNull(); + assertThat(result.getResponseAsScimResource()).isNull(); + assertThat(result.getBulkId()).isEqualTo("qwerty"); + assertThat(result.getVersion()).isNull(); + + // Test the basic constructor. + result = new BulkOperationResult( + DELETE, + HTTP_STATUS_NO_CONTENT, + location, + null, + null, + null + ); + assertThat(result.getMethod()).isEqualTo(DELETE); + assertThat(result.getStatus()).isEqualTo("204"); + assertThat(result.getLocation()).isEqualTo(location); + assertThat(result.getResponse()).isNull(); + assertThat(result.getResponseAsScimResource()).isNull(); + assertThat(result.getBulkId()).isNull(); + assertThat(result.getVersion()).isNull(); + + // Ensure that locations may not be null for non-POST requests. + List operationList = List.of( + BulkOperation.put(location, new UserResource()), + BulkOperation.patch(location, PatchOperation.remove("userName")), + BulkOperation.delete(location) + ); + for (BulkOperation op : operationList) + { + assertThatThrownBy(() -> new BulkOperationResult( + op, ServerErrorException.status(), null)) + .isInstanceOf(BulkResponseException.class) + .hasMessageContaining("The 'location' of a BulkOperationResult") + .hasMessageContaining("must be defined"); + } + + // Ensure a failed POST operation is permitted to set a null location. + new BulkOperationResult( + BulkOperation.post("https://example.com/v2/Groups", new GroupResource()), + ServerErrorException.status(), + null + ); + } + + /** + * Test the {@link BulkOperationResult#getResponseAsScimResource()} method. + */ + @Test + public void testFetchingResponse() + { + // The SCIM standard does not force service providers to return a response, + // presumably to avoid large payload problems. Ensure a null value simply + // returns null. + BulkOperationResult result = new BulkOperationResult( + BulkOpType.PUT, "200", "https://example.com/v2/aaa", null, null, null); + assertThat(result.getResponseAsScimResource()).isNull(); + + // Ensure a well-defined user is decoded appropriately. + UserResource user = new UserResource().setUserName("premonition"); + result.setResponse(user); + assertThat(result.getResponseAsScimResource()) + .isInstanceOf(UserResource.class) + .isEqualTo(user); + + // An invalid resource should cause an exception. + ObjectNode invalidNode = result.getResponse(); + assertThat(invalidNode).isNotNull(); + invalidNode.set("emails", TextNode.valueOf("invalidSingleValue")); + result.setResponse(invalidNode); + assertThatThrownBy(result::getResponseAsScimResource) + .isInstanceOf(BulkResponseException.class) + .hasMessageContaining("A malformed JSON failed to be converted into a") + .hasMessageContaining("SCIM resource."); + + // An error result should result in an ErrorResponse object. + BulkOperationResult error = new BulkOperationResult( + new ForbiddenException("Disallowed."), BulkOpType.POST, null); + assertThat(error.getResponseAsScimResource()) + .isInstanceOf(ErrorResponse.class) + .asString().contains("Disallowed."); + } + + /** + * Validate the setter methods. + */ + @Test + public void testSetters() + { + BulkOperationResult result = new BulkOperationResult( + BulkOpType.POST, HTTP_STATUS_OK, location, null, null, null); + + // Overwrite the location. + result.setLocation("newLocation"); + assertThat(result.getLocation()).isEqualTo("newLocation"); + + // Set all remaining fields. + result.setBulkId("qwerty") + .setVersion("newVersion") + .setStatus(ResourceNotFoundException.status()) + .setResponse(JsonUtils.getJsonNodeFactory().objectNode()); + assertThat(result.getBulkId()).isEqualTo("qwerty"); + assertThat(result.getVersion()).isEqualTo("newVersion"); + assertThat(result.getStatus()).isEqualTo("404"); + assertThat(result.isClientError()).isTrue(); + assertThat(result.isServerError()).isFalse(); + + // An empty object node should be returned as an empty GenericScimResource. + assertThat(result.getResponse()).isNotNull().isEmpty(); + assertThat(result.getResponseAsScimResource()) + .isEqualTo(new GenericScimResource()); + + // Use the other setter variants that accept other object types. + result.setResponse(new UserResource()) + .setStatus(500); + assertThat(result.getResponse()).isNotNull().isNotEmpty(); + assertThat(result.getStatus()).isEqualTo("500"); + assertThat(result.isClientError()).isFalse(); + assertThat(result.isServerError()).isTrue(); + + // Passing a null UserResource should behave identically to the other + // variant. + result.setResponse((UserResource) null); + assertThat(result.getResponse()).isNull(); + assertThat(result.getResponseAsScimResource()).isNull(); + } + + /** + * Validate passing {@code null} arguments to the constructors and methods. + */ + @SuppressWarnings("DataFlowIssue") + @Test + public void testSettingNull() + { + // Check the constructors for null operations and statuses. + assertThatThrownBy(() -> + new BulkOperationResult((BulkOperation) null, "500", location)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> + new BulkOperationResult(BulkOperation.delete("/val"), null, location)) + .isInstanceOf(NullPointerException.class); + + // Create a result. + BulkOperationResult result = new BulkOperationResult( + PUT, "200", location, null, null, null); + + // Setting the status should never accept null. + assertThatThrownBy(() -> result.setStatus((String) null)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> result.setStatus((Integer) null)) + .isInstanceOf(NullPointerException.class); + + // setLocation() should not accept null for non-POST requests. + for (var opType : List.of(PUT, PATCH, DELETE)) + { + BulkOperationResult locationResult = new BulkOperationResult( + opType, "200", location, null, null, null); + assertThatThrownBy(() -> locationResult.setLocation(null)) + .isInstanceOf(NullPointerException.class); + } + + // Ensure a null location is permissible for a POST. + new BulkOperationResult(POST, "429", location, null, null, null) + .setLocation(null); + } + + /** + * Validate the {@link BulkOperationResult} constructors for creating errors. + */ + @Test + public void testErrors() throws Exception + { + // Validate the method that accepts an operation type. + BulkOperationResult error = new BulkOperationResult( + new ServerErrorException("Internal Server Error"), DELETE, location); + assertThat(error.getMethod()).isEqualTo(DELETE); + assertThat(error.getLocation()).isEqualTo(location); + assertThat(error.getBulkId()).isNull(); + assertThat(error.getVersion()).isNull(); + assertThat(error.getStatus()).isEqualTo("500"); + assertThat(error.getStatusInt()).isEqualTo(500); + assertThat(error.getResponse()).isNotNull(); + + // Use the ScimResource-based object for validation since it is easier to + // work with. + assertThat(error.getResponseAsScimResource()) + .isInstanceOf(ErrorResponse.class) + .satisfies(e -> { + var response = (ErrorResponse) e; + assertThat(response.getStatus()).isEqualTo(500); + assertThat(response.getDetail()).isEqualTo("Internal Server Error"); + }); + + // Validate the method that accepts a bulk operation. + error = new BulkOperationResult( + new ForbiddenException("User is unauthorized."), + BulkOperation.patch(location, PatchOperation.replace("userName", "a")), + location); + assertThat(error.getMethod()).isEqualTo(PATCH); + assertThat(error.getLocation()).isEqualTo(location); + assertThat(error.getBulkId()).isNull(); + assertThat(error.getVersion()).isNull(); + assertThat(error.getStatus()).isEqualTo("403"); + assertThat(error.getStatusInt()).isEqualTo(403); + assertThat(error.getResponse()).isNotNull(); + assertThat(error.getResponseAsScimResource()) + .isInstanceOf(ErrorResponse.class) + .satisfies(e -> { + var response = (ErrorResponse) e; + assertThat(response.getStatus()).isEqualTo(403); + assertThat(response.getDetail()).isEqualTo("User is unauthorized."); + }); + + // Ensure that a bulk POST operation error retains the bulk ID. + String usersURI = "https://example.com/v2/Users"; + error = new BulkOperationResult( + new RateLimitException("Too many requests. Please try again later."), + BulkOperation.post(usersURI, new UserResource()).setBulkId("value"), + location); + assertThat(error.getBulkId()).isEqualTo("value"); + + // Even though a location value was provided, the SCIM SDK should ensure + // that the result location should be null. This is because a failed POST + // means that the resource was not created, so it cannot have a location. + assertThat(error.getLocation()).isNull(); + + // Validate all remaining fields. + assertThat(error.getMethod()).isEqualTo(POST); + assertThat(error.getVersion()).isNull(); + assertThat(error.getStatus()).isEqualTo(RateLimitException.status()); + assertThat(error.getStatusInt()).isEqualTo(RateLimitException.statusInt()); + assertThat(error.getResponse()).isNotNull(); + assertThat(error.getResponseAsScimResource()) + .isInstanceOf(ErrorResponse.class) + .satisfies(e -> { + var response = (ErrorResponse) e; + assertThat(response.getStatus()).isEqualTo(429); + assertThat(response.getDetail()).startsWith("Too many requests."); + }); + } + + /** + * Test {@link BulkOperationResult#equals(Object)}. + */ + @Test + @SuppressWarnings("all") + public void testEquals() + { + BulkOperationResult first = new BulkOperationResult( + PATCH, + HTTP_STATUS_OK, + "https://example.com/v2/Users/userID", + JsonUtils.getJsonNodeFactory().objectNode(), + null, + "versionTag" + ); + BulkOperationResult second = first.copy(); + + assertThat(first.equals(second)).isTrue(); + assertThat(first.equals(first)).isTrue(); + assertThat(first.hashCode()).isEqualTo(second.hashCode()); + assertThat(first.equals(null)).isFalse(); + assertThat(first.equals(BulkOperation.delete("/Users/userID"))).isFalse(); + + second.setLocation("other"); + assertThat(first.equals(second)).isFalse(); + second = first.copy(); + second.setVersion("otherVersion"); + assertThat(first.equals(second)).isFalse(); + second = first.copy(); + + second.setStatus("418"); + assertThat(first.equals(second)).isFalse(); + second = first.copy(); + + var node = JsonUtils.getJsonNodeFactory().objectNode(); + node.put("id", "value"); + second.setResponse(node); + assertThat(first.equals(second)).isFalse(); + second = first.copy(); + + // Result objects of different types should not be equal. + BulkOperationResult other = new BulkOperationResult( + PUT, + HTTP_STATUS_OK, + "https://example.com/v2/Users/userID", + JsonUtils.getJsonNodeFactory().objectNode(), + "qwerty", + "versionTag" + ); + assertThat(first.equals(other)).isFalse(); + + // Test the bulk ID. This is only relevant to a POST request. + BulkOperationResult post = new BulkOperationResult( + POST, + HTTP_STATUS_CREATED, + "https://example.com/v2/Users/userID", + JsonUtils.getJsonNodeFactory().objectNode(), + "qwerty", + null + ); + BulkOperationResult postCopy = post.copy(); + assertThat(post.equals(postCopy)).isTrue(); + postCopy.setBulkId("otherID"); + assertThat(post.equals(postCopy)).isFalse(); + } + + /** + * Ensures that copying a bulk operation result produces a new, equivalent + * object. + */ + @Test + public void testCopy() + { + BulkOperationResult original = new BulkOperationResult( + PUT, + HTTP_STATUS_OK, + "https://example.com/v2/Users/userID", + JsonUtils.getJsonNodeFactory().objectNode(), + "qwerty", + "versionTag" + ); + BulkOperationResult copy = original.copy(); + + // The references should not match, but the objects should be equal. + assertThat(original == copy).isFalse(); + assertThat(original).isEqualTo(copy); + + // The copied response ObjectNode should be a separate object. + assertThat(original.getResponse() == copy.getResponse()).isFalse(); + assertThat(original.getResponse()).isEqualTo(copy.getResponse()); + + // Null response ObjectNodes should be handled properly when copied. + BulkOperationResult delete = new BulkOperationResult( + DELETE, + HTTP_STATUS_NO_CONTENT, + "https://example.com/v2/Users/userID", + null, + "qwerty", + "versionTag" + ); + assertThat(delete.copy()).isEqualTo(delete); + assertThat(delete.copy() == delete).isFalse(); + } + + /** + * Test {@link BulkOperationResult#setAny}. + */ + @Test + public void testSetAny() + { + // Bulk operations should always ignore unknown fields when deserializing, + // so the @JsonAnySetter method should effectively be a no-op. Ensure this + // is the case by showing that valid keys/fields make no update. + BulkOperationResult result = new BulkOperationResult( + POST, + "201", + "https://example.com/Users/userID", + null, + null, + null + ); + var result2 = result.copy(); + result.setAny("method", TextNode.valueOf("fieldDoesNotExist")); + assertThat(result).isEqualTo(result2); + + result.setAny("status", TextNode.valueOf("200")); + assertThat(result.getStatus()) + .isNotEqualTo("200") + .isEqualTo("201"); + assertThat(result).isEqualTo(result2); + } +} diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkOperationTest.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkOperationTest.java new file mode 100644 index 00000000..9a3c1089 --- /dev/null +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkOperationTest.java @@ -0,0 +1,456 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.common.bulk; + +import com.fasterxml.jackson.core.JacksonException; +import com.fasterxml.jackson.databind.ObjectReader; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import com.unboundid.scim2.common.exceptions.BulkRequestException; +import com.unboundid.scim2.common.messages.PatchOperation; +import com.unboundid.scim2.common.messages.PatchRequest; +import com.unboundid.scim2.common.types.Email; +import com.unboundid.scim2.common.types.GroupResource; +import com.unboundid.scim2.common.types.UserResource; +import com.unboundid.scim2.common.utils.JsonUtils; +import org.testng.annotations.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/** + * A collection of tests for the {@link BulkOperation} class. + */ +public class BulkOperationTest +{ + private static final ObjectReader reader = JsonUtils.getObjectReader(); + + /** + * Tests the behavior of bulk delete operations. + */ + @Test + public void testDelete() throws Exception + { + // Instantiate a bulk delete operation and verify the result. + BulkOperation operation = BulkOperation.delete("/Users/customResource") + .setVersion("versionValue"); + assertThat(operation.getMethod()).isEqualTo(BulkOpType.DELETE); + assertThat(operation.getPath()).isEqualTo("/Users/customResource"); + assertThat(operation.getVersion()).isEqualTo("versionValue"); + assertThat(operation.getBulkId()).isNull(); + assertThat(operation.getData()).isNull(); + + // Validate serialization of the operation by ensuring the serialized string + // is equivalent to the expected JSON output. + String expectedString = """ + { + "method": "DELETE", + "path": "/Users/customResource", + "version": "versionValue" + }"""; + String normalizedJSON = reader.readTree(expectedString).toPrettyString(); + assertThat(operation.toString()).isEqualTo(normalizedJSON); + + // Deserialize the JSON data and verify that the resulting object is + // equivalent to the initial operation. + BulkOperation deserialized = reader.forType(BulkOperation.class) + .readValue(expectedString); + assertThat(operation).isEqualTo(deserialized); + + // Setting the bulk ID should not be permitted for a delete operation. + assertThatThrownBy(() -> operation.setBulkId("invalid")) + .isInstanceOf(BulkRequestException.class) + .hasMessageContaining("Bulk IDs may only be set for POST requests") + .hasMessageContaining("Invalid HTTP method: DELETE"); + } + + /** + * Tests the behavior of bulk POST operations. + */ + @Test + public void testPost() throws Exception + { + // Instantiate a post operation. + UserResource newUser = new UserResource().setUserName("kendrick.lamar"); + BulkOperation operation = BulkOperation.post("/Users", newUser); + + assertThat(operation.getMethod()).isEqualTo(BulkOpType.POST); + assertThat(operation.getPath()).isEqualTo("/Users"); + assertThat(operation.getBulkId()).isNull(); + assertThat(operation.getVersion()).isNull(); + assertThat(operation.getData()) + .isNotNull() + .isEqualTo(newUser.asGenericScimResource().getObjectNode()); + + // Ensure that it is possible to set the bulk ID. + operation.setBulkId("qwerty2"); + assertThat(operation.getBulkId()).isEqualTo("qwerty2"); + + // Validate serialization of the operation. + String expectedString = """ + { + "method": "POST", + "path": "/Users", + "bulkId": "qwerty2", + "data": { + "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], + "userName": "kendrick.lamar" + } + }"""; + String normalizedJSON = reader.readTree(expectedString).toPrettyString(); + assertThat(operation.toString()).isEqualTo(normalizedJSON); + + // Validate deserialization of the JSON. + BulkOperation deserialized = reader.forType(BulkOperation.class) + .readValue(expectedString); + assertThat(operation).isEqualTo(deserialized); + + // Ensure that the version field cannot be set for bulk posts. + assertThatThrownBy(() -> operation.setVersion("nonsensical")) + .isInstanceOf(BulkRequestException.class) + .hasMessageContaining("Cannot set the 'version' field") + .hasMessageContaining("of a bulk POST operation"); + } + + + /** + * Tests the behavior of bulk PUT operations. + */ + @Test + public void testPut() throws Exception + { + // Instantiate a bulk put operation. + UserResource newUserData = new UserResource() + .setUserName("kendrick.lamar") + .setEmails(new Email().setValue("NLU@example.com")); + BulkOperation operation = BulkOperation.put("/Users/resource", newUserData) + .setVersion("versionBeforeUpdate"); + + assertThat(operation.getMethod()).isEqualTo(BulkOpType.PUT); + assertThat(operation.getPath()).isEqualTo("/Users/resource"); + assertThat(operation.getBulkId()).isNull(); + assertThat(operation.getVersion()).isEqualTo("versionBeforeUpdate"); + assertThat(operation.getData()) + .isNotNull() + .isEqualTo(newUserData.asGenericScimResource().getObjectNode()); + + // Validate serialization of the operation. + String expectedString = """ + { + "method": "PUT", + "path": "/Users/resource", + "version": "versionBeforeUpdate", + "data": { + "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], + "userName": "kendrick.lamar", + "emails": [ { + "value": "NLU@example.com" + } ] + } + }"""; + String normalizedJSON = reader.readTree(expectedString).toPrettyString(); + assertThat(operation.toString()).isEqualTo(normalizedJSON); + + // Validate deserialization of the JSON. + BulkOperation deserialized = reader.forType(BulkOperation.class) + .readValue(expectedString); + assertThat(operation).isEqualTo(deserialized); + + // Ensure that it is not possible to set the bulkID. + assertThatThrownBy(() -> operation.setBulkId("invalid")) + .isInstanceOf(BulkRequestException.class) + .hasMessageContaining("Bulk IDs may only be set for POST requests") + .hasMessageContaining("Invalid HTTP method: PUT"); + } + + /** + * Tests the behavior of bulk patch operations. + */ + @Test + public void testPatch() throws Exception + { + // Instantiate a bulk patch operation. + var patchOp = PatchOperation.replace("displayName", "New Value"); + BulkOperation operation = BulkOperation.patch("/Groups/resource", patchOp) + .setVersion("newVersion"); + + assertThat(operation.getMethod()).isEqualTo(BulkOpType.PATCH); + assertThat(operation.getPath()).isEqualTo("/Groups/resource"); + assertThat(operation.getBulkId()).isNull(); + assertThat(operation.getVersion()).isEqualTo("newVersion"); + assertThat(operation.getData()).isNotNull(); + + // Validate serialization of the bulk operation. + String expectedString = """ + { + "method": "PATCH", + "path": "/Groups/resource", + "version": "newVersion", + "data": { + "Operations": [ + { + "op": "replace", + "path": "displayName", + "value": "New Value" + } + ] + } + }"""; + String normalizedJSON = reader.readTree(expectedString).toPrettyString(); + assertThat(operation.toString()).isEqualTo(normalizedJSON); + + // Validate deserialization of the JSON. + BulkOperation deserialized = reader.forType(BulkOperation.class) + .readValue(expectedString); + assertThat(operation).isEqualTo(deserialized); + } + + /** + * Validates attempts to deserialize invalid bulk operations. + */ + @Test + public void testImproperlyFormattedPatch() + { + String patchWithNoOperations = """ + { + "method": "PATCH", + "path": "/Groups/resource", + "data": { + } + }"""; + assertThatThrownBy(() -> + reader.forType(BulkOperation.class).readValue(patchWithNoOperations)) + .isInstanceOf(JacksonException.class) + .hasMessageContaining("Could not parse the patch operation list") + .hasMessageContaining("because the value of 'Operations' is absent"); + + String operationsNotArray = """ + { + "method": "PATCH", + "path": "/Groups/resource", + "data": { + "Operations": { + "op": "replace", + "path": "displayName", + "value": "New Value Attempt" + } + } + }"""; + assertThatThrownBy(() -> + reader.forType(BulkOperation.class).readValue(operationsNotArray)) + .isInstanceOf(JacksonException.class) + .hasMessageContaining("Could not parse the patch operation list") + .hasMessageContaining("because the 'Operations' field is not an array"); + + // Attempt an invalid PatchOperation that is missing the required 'op' field. + String badOperationFormat = """ + { + "method": "PATCH", + "path": "/Groups/resource", + "data": { + "Operations": [ + { + "path": "myAttribute", + "value": "value without operation type" + } + ] + } + }"""; + assertThatThrownBy(() -> + reader.forType(BulkOperation.class).readValue(badOperationFormat)) + .isInstanceOf(JacksonException.class) + .hasMessageContaining("Failed to convert an array to a patch") + .hasMessageContaining("operation list"); + } + + /** + * Validate {@link BulkOperation#getPatchOperationList()}. + */ + @Test + public void testPatchOpList() throws Exception + { + BulkOperation emptyBulkPatch = + BulkOperation.patch("/Users/userID", (List) null); + assertThat(emptyBulkPatch.getPatchOperationList()).isEmpty(); + + List patchList = List.of( + PatchOperation.addStringValues("customPhoneNumbers", "512-111-1111"), + PatchOperation.remove("addresses"), + PatchOperation.replace("userName", "myUserName") + ); + BulkOperation bulkPatch = BulkOperation.patch("/Users/userID", patchList); + assertThat(bulkPatch.getPatchOperationList()).isEqualTo(patchList); + + // The method should only be allowed for PATCH operations. + ObjectNode node = JsonUtils.getJsonNodeFactory().objectNode(); + assertThatThrownBy(() -> { + BulkOperation.post("/Users", node).getPatchOperationList(); + }).isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> { + BulkOperation.put("/Users/userID", node).getPatchOperationList(); + }).isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> { + BulkOperation.delete("/Users/userID").getPatchOperationList(); + }).isInstanceOf(IllegalStateException.class); + } + + /** + * Test the {@link BulkOperation#getDataAsScimResource()} method. + */ + @Test + public void testFetchingData() throws Exception + { + UserResource dataSource = new UserResource().setUserName("premonition") + .setDisplayName("Premo"); + BulkOperation op = BulkOperation.post("/Users", dataSource); + assertThat(op.getDataAsScimResource()) + .isInstanceOf(UserResource.class) + .isEqualTo(dataSource); + + // A bulk patch operation should be returned as a PatchRequest. + BulkOperation patch = BulkOperation.patch("/Users/fa1afe1", + PatchOperation.add("userName", TextNode.valueOf("newUser")), + PatchOperation.replace("title", "newHire")); + assertThat(patch.getDataAsScimResource()).isInstanceOf(PatchRequest.class); + PatchRequest patchRequest = (PatchRequest) patch.getDataAsScimResource(); + assertThat(patchRequest.getOperations()) + .hasSize(2) + .containsExactly( + PatchOperation.add("userName", TextNode.valueOf("newUser")), + PatchOperation.replace("title", "newHire")); + + // Delete operations do not have data. Ensure a null value is returned. + BulkOperation delete = BulkOperation.delete("/Users/fa1afe1"); + assertThat(delete.getDataAsScimResource()).isNull(); + + // Create an invalid user resource. + ObjectNode invalidNode = op.getData(); + assertThat(invalidNode).isNotNull(); + invalidNode.set("emails", TextNode.valueOf("invalidSingleValue")); + BulkOperation opWithInvalidData = BulkOperation.post("/Users", invalidNode); + assertThatThrownBy(opWithInvalidData::getDataAsScimResource) + .isInstanceOf(BulkRequestException.class) + .hasMessageContaining("A malformed JSON failed to be converted into a") + .hasMessageContaining(" SCIM resource."); + } + + /** + * Ensures that copying a bulk operation produces a new, equivalent object. + */ + @Test + public void testCopy() throws Exception + { + List operations = List.of( + BulkOperation.post("/Groups", new GroupResource()) + .setBulkId("qwerty"), + BulkOperation.put("/Groups/id", new GroupResource()) + .setVersion("version"), + BulkOperation.patch("/Groups/id", PatchOperation.remove("emails")) + .setVersion("version"), + BulkOperation.delete("/Groups/id").setVersion("version") + ); + + for (BulkOperation operation : operations) + { + BulkOperation copy = operation.copy(); + assertThat(operation.equals(copy)).isTrue(); + assertThat(operation == copy).isFalse(); + } + } + + /** + * Test {@link BulkOperation#equals(Object)}. + */ + @Test + @SuppressWarnings("all") + public void testEquals() + { + var objectNode = new UserResource().asGenericScimResource().getObjectNode(); + BulkOperation put = BulkOperation.put("/Users/userID", objectNode); + + assertThat(put.equals(put)).isTrue(); + assertThat(put.equals(null)).isFalse(); + assertThat(put.equals(objectNode)).isFalse(); + + // Operations of different types should not be equal. + assertThat(put.equals(BulkOperation.delete("/Users/userID"))).isFalse(); + + // Operations of the same type that reference a different path should not be + // equal. + assertThat(put.equals(BulkOperation.put("/Users/otherID", objectNode))) + .isFalse(); + + // Operations of the same type that do not share the same data should not be + // equal. + var emptyNode = JsonUtils.getJsonNodeFactory().objectNode(); + assertThat(put.equals(BulkOperation.put("/Users/userID", emptyNode))) + .isFalse(); + + final BulkOperation other = BulkOperation.put("/Users/userID", objectNode); + + // Operations with different bulk IDs should not be equal. + BulkOperation postOp1 = BulkOperation.post("/Users", objectNode) + .setBulkId("first"); + BulkOperation postOp2 = BulkOperation.post("/Users", objectNode) + .setBulkId("second"); + assertThat(postOp1.equals(postOp2)).isFalse(); + assertThat(postOp1.hashCode()).isNotEqualTo(postOp2.hashCode()); + + // Operations with different ETag values should not be equal. + put.setVersion("versionValue"); + other.setVersion("otherVersion"); + assertThat(put.equals(other)).isFalse(); + other.setVersion("versionValue"); + } + + /** + * Test {@link BulkOperation#setAny}. + */ + @Test + public void testSetAny() + { + // Bulk operations should always ignore unknown fields when deserializing, + // so the @JsonAnySetter method should effectively be a no-op. Ensure this + // is the case by showing that valid keys/fields make no update. + BulkOperation operation = BulkOperation.post("/Users", new UserResource()); + var operation2 = operation.copy(); + operation.setAny("method", TextNode.valueOf("DELETE")); + assertThat(operation).isEqualTo(operation2); + + operation.setAny("doesNotExist", TextNode.valueOf("Other")); + assertThat(operation).isEqualTo(operation2); + } +} diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkRequestTest.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkRequestTest.java new file mode 100644 index 00000000..dc77d5b3 --- /dev/null +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkRequestTest.java @@ -0,0 +1,549 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.common.bulk; + +import com.fasterxml.jackson.core.JacksonException; +import com.fasterxml.jackson.databind.JsonNode; +import com.unboundid.scim2.common.BaseScimResource; +import com.unboundid.scim2.common.ScimResource; +import com.unboundid.scim2.common.exceptions.BulkRequestException; +import com.unboundid.scim2.common.messages.PatchOperation; +import com.unboundid.scim2.common.types.GroupResource; +import com.unboundid.scim2.common.types.Member; +import com.unboundid.scim2.common.types.UserResource; +import com.unboundid.scim2.common.utils.JsonUtils; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +import static com.unboundid.scim2.common.utils.ApiConstants.BULK_PREFIX; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.fail; + + +/** + * Unit tests for {@link BulkRequest}. + */ +public class BulkRequestTest +{ + /** + * Resets a customizable property. + */ + @AfterMethod + public void tearDown() + { + BaseScimResource.IGNORE_UNKNOWN_FIELDS = false; + } + + /** + * Performs basic validation on the BulkRequest class. + */ + @Test + public void testBasic() + { + //noinspection DataFlowIssue + assertThatThrownBy(() -> new BulkRequest((Integer) null, null)) + .isInstanceOf(BulkRequestException.class); + + // Passing in null to BulkRequest(List) should be handled safely. + BulkRequest request = new BulkRequest(null); + assertThat(request.getOperations()).isEmpty(); + assertThat(request.getFailOnErrors()).isNull(); + var deleteOperation = BulkOperation.delete("/Users/fa1afe1"); + request = new BulkRequest( + Arrays.asList(null, null, deleteOperation)); + assertThat(request.getOperations()).hasSize(1); + assertThat(request.getOperations()).containsExactly(deleteOperation); + + // For the BulkRequest(BulkOperation, BulkOperation...) variant, null values + // should also be handled. + request = new BulkRequest( + BulkOperation.delete("value"), (BulkOperation[]) null); + assertThat(request.getFailOnErrors()).isNull(); + assertThat(request.getOperations()).hasSize(1); + + request = new BulkRequest( + BulkOperation.delete("value"), + null, + BulkOperation.delete("value2")); + assertThat(request.getFailOnErrors()).isNull(); + + // Ensure that the list has the operations, and is immutable. + assertThat(request.getOperations()) + .hasSize(2) + .isUnmodifiable(); + + // It should be possible to iterate over the bulk request in an enhanced + // for-loop. + for (BulkOperation op : request) + { + assertThat(op.getMethod()).isEqualTo(BulkOpType.DELETE); + } + } + + + /** + * Validates the JSON form when bulk requests are serialized into strings. + */ + @Test + public void testSerialization() throws Exception + { + String json = """ + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:BulkRequest" ], + "failOnErrors": 2, + "Operations": [ { + "method": "POST", + "path": "/Users", + "data": { + "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], + "userName": "Alice" + } + } ] + }"""; + + // Reformat the string in a standardized form. + final String expectedJSON = JsonUtils.getObjectReader() + .readTree(json).toPrettyString(); + + // Ensure all constructors can create the request. + BulkRequest request; + final BulkOperation operation = BulkOperation.post( + "/Users", new UserResource().setUserName("Alice")); + + request = new BulkRequest(2, List.of(operation)); + assertThat(request.toString()).isEqualTo(expectedJSON); + + request = new BulkRequest(List.of(operation)).setFailOnErrors(2); + assertThat(request.toString()).isEqualTo(expectedJSON); + + request = new BulkRequest(operation).setFailOnErrors(2); + assertThat(request.toString()).isEqualTo(expectedJSON); + } + + /** + * Validates deserialization of a bulk request. + */ + @Test + public void testDeserialization() throws Exception + { + final var reader = JsonUtils.getObjectReader().forType(BulkRequest.class); + + // Example JSON bulk request from RFC 7644. This was slightly revised to + // include the "Operations" attribute within the bulk patch request (the + // third bulk operation) so that it's a valid JSON with key-value pairs. + String json = """ + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:BulkRequest" ], + "failOnErrors": 1, + "Operations": [ + { + "method": "POST", + "path": "/Users", + "bulkId": "qwerty", + "data": { + "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], + "userName": "Alice" + } + }, + { + "method": "PUT", + "path": "/Users/b7c14771-226c-4d05-8860-134711653041", + "version": "W/\\"3694e05e9dff591\\"", + "data": { + "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], + "id": "b7c14771-226c-4d05-8860-134711653041", + "userName": "Bob" + } + }, + { + "method": "PATCH", + "path": "/Users/5d8d29d3-342c-4b5f-8683-a3cb6763ffcc", + "version": "W/\\"edac3253e2c0ef2\\"", + "data": { + "Operations": [ + { + "op": "remove", + "path": "nickName" + }, + { + "op": "replace", + "path": "userName", + "value": "Dave" + } + ] + } + }, + { + "method": "DELETE", + "path": "/Users/e9025315-6bea-44e1-899c-1e07454e468b", + "version": "W/\\"0ee8add0a938e1a\\"" + } + ] + }"""; + BulkRequest request = reader.readValue(json); + assertThat(request.getFailOnErrors()).isEqualTo(1); + + // Validate the operations list and its contents. + final List operations = request.getOperations(); + assertThat(operations).hasSize(4); + + UserResource alice = new UserResource().setUserName("Alice"); + assertThat(operations.get(0)).satisfies(op -> { + assertThat(op.getMethod()).isEqualTo(BulkOpType.POST); + assertThat(op.getPath()).isEqualTo("/Users"); + assertThat(op.getBulkId()).isEqualTo("qwerty"); + assertThat(op.getData()).isNotNull(); + assertThat(op.getData().toPrettyString()).isEqualTo(alice.toString()); + assertThat(op.getVersion()).isNull(); + }); + + UserResource bob = new UserResource().setUserName("Bob"); + bob.setId("b7c14771-226c-4d05-8860-134711653041"); + assertThat(operations.get(1)).satisfies(op -> { + assertThat(op.getMethod()).isEqualTo(BulkOpType.PUT); + assertThat(op.getPath()) + .isEqualTo("/Users/b7c14771-226c-4d05-8860-134711653041"); + assertThat(op.getBulkId()).isNull(); + assertThat(op.getData()).isNotNull(); + assertThat(op.getData().toPrettyString()).isEqualTo(bob.toString()); + assertThat(op.getVersion()).isEqualTo("W/\"3694e05e9dff591\""); + }); + + List expectedPatchOperations = List.of( + JsonUtils.valueToNode(PatchOperation.remove("nickName")), + JsonUtils.valueToNode(PatchOperation.replace("userName", "Dave"))); + assertThat(operations.get(2)).satisfies(op -> { + assertThat(op.getMethod()).isEqualTo(BulkOpType.PATCH); + assertThat(op.getPath()) + .isEqualTo("/Users/5d8d29d3-342c-4b5f-8683-a3cb6763ffcc"); + assertThat(op.getBulkId()).isNull(); + assertThat(op.getData()).isNotNull(); + assertThat(op.getData().get("Operations")) + .hasSize(2) + .containsExactlyInAnyOrderElementsOf(expectedPatchOperations); + assertThat(op.getVersion()).isEqualTo("W/\"edac3253e2c0ef2\""); + }); + + assertThat(operations.get(3)).satisfies(op -> { + assertThat(op.getMethod()).isEqualTo(BulkOpType.DELETE); + assertThat(op.getPath()) + .isEqualTo("/Users/e9025315-6bea-44e1-899c-1e07454e468b"); + assertThat(op.getBulkId()).isNull(); + assertThat(op.getData()).isNull(); + assertThat(op.getVersion()).isEqualTo("W/\"0ee8add0a938e1a\""); + }); + + // Test empty bulk requests. + String emptyOpsJson = """ + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:BulkRequest" ], + "failOnErrors": 10, + "Operations": [] + }"""; + request = reader.readValue(emptyOpsJson); + assertThat(request.getFailOnErrors()).isEqualTo(10); + assertThat(request.getOperations()).isEmpty(); + + // The SCIM SDK treats ListResponse objects with special handling when the + // "Resources" array is not present in the JSON, as this behavior is + // mandated by the RFC. We should exhibit the same behavior with bulk + // requests for broad compatibility. Treat a null array as empty. + String missingOpsJson = """ + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:BulkRequest" ], + "failOnErrors": 200 + }"""; + request = reader.readValue(missingOpsJson); + assertThat(request.getFailOnErrors()).isEqualTo(200); + assertThat(request.getOperations()).isEmpty(); + + // A missing value for failOnErrors should be treated as null. + String missingFailOnErrors = """ + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:BulkRequest" ], + "Operations": [] + }"""; + request = reader.readValue(missingFailOnErrors); + assertThat(request.getFailOnErrors()).isNull(); + assertThat(request.getOperations()).isEmpty(); + + // Attempt deserializing an incorrect object into a bulk request. + String invalidJson = """ + { + "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], + "userName": "muhammad.ali", + "title": "Champ", + "emails": [{ + "value": "ali@example.com", + "primary": true + }] + }"""; + + // By default, we should see a JacksonException. + assertThatThrownBy(() -> reader.readValue(invalidJson)) + .isInstanceOf(JacksonException.class); + + // If the configuration setting is set to allow unknown attributes without + // failing, then we should still have an exception to indicate that no data + // was successfully copied. In this test case, a user resource should appear + // to deserialize successfully into a BulkRequest. + // + // The method throws a BulkRequestException, but Jackson re-throws this as + // one of its own exception types. + BaseScimResource.IGNORE_UNKNOWN_FIELDS = true; + assertThatThrownBy(() -> reader.readValue(invalidJson)) + .isInstanceOf(JacksonException.class); + + // Explicitly null values for both attributes should fail, as this is the + // signal we use that the source JSON was most likely not an actual bulk + // request. + String veryEmpty = """ + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:BulkRequest" ] + }"""; + assertThatThrownBy(() -> reader.readValue(veryEmpty)) + .isInstanceOf(JacksonException.class); + + // Bulk IDs should only be set for POST requests. Ensure that when a bulk + // operation is deserialized, an exception is thrown for an invalid bulk ID. + String invalidBulkIDJson = """ + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:BulkRequest" ], + "failOnErrors": 1, + "Operations": [ + { + "method": "PUT", + "path": "/Users", + "bulkId": "shouldNotBePermitted", + "data": { + "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], + "userName": "Alice" + } + } + ] + }"""; + assertThatThrownBy(() -> reader.readValue(invalidBulkIDJson)) + .isInstanceOf(JacksonException.class) + .hasMessageContaining("Bulk IDs may only be set for POST requests.") + .hasMessageContaining("Invalid HTTP method: PUT"); + } + + /** + * Test {@link BulkRequest#getFailOnErrors()} and related methods. + */ + @Test + public void testFailOnErrors() + { + BulkRequest request = new BulkRequest( + BulkOperation.post("/Users", new UserResource())); + + // By default, the value should be null, indicating that all requests should + // be attempted. + assertThat(request.getFailOnErrors()).isNull(); + assertThat(request.getFailOnErrorsNormalized()) + .isEqualTo(Integer.MAX_VALUE); + + request.setFailOnErrors(10); + assertThat(request.getFailOnErrors()).isEqualTo(10); + assertThat(request.getFailOnErrorsNormalized()).isEqualTo(10); + + // Values less than 1 should not be permitted. A client can request that a + // bulk request's processing is halted after a single failure, but halting + // after 0 or negative failures is not well-defined. + assertThatThrownBy(() -> request.setFailOnErrors(-1)) + .isInstanceOf(BulkRequestException.class) + .hasMessageContaining("The failOnErrors value (-1) may not be less") + .hasMessageContaining("than 1."); + assertThatThrownBy(() -> request.setFailOnErrors(0)) + .isInstanceOf(BulkRequestException.class) + .hasMessageContaining("The failOnErrors value (0) may not be less") + .hasMessageContaining("than 1."); + } + + /** + * Validate {@link BulkRequest#equals(Object)}. + */ + @Test + @SuppressWarnings("all") + public void testEquals() + { + BulkRequest first = new BulkRequest(BulkOperation.delete("value")) + .setFailOnErrors(1); + BulkRequest second = new BulkRequest(BulkOperation.delete("value")) + .setFailOnErrors(1); + + assertThat(first.equals(first)).isTrue(); + assertThat(first.equals(null)).isFalse(); + assertThat(first.equals(new UserResource())).isFalse(); + assertThat(first.equals(second)).isTrue(); + + // Before the next phase, ensure the hash codes are identical. + assertThat(first.hashCode()).isEqualTo(second.hashCode()); + + // Clear the second value request's failure count so that the requests are + // unequal. Now, the hash codes should be different. + second.setFailOnErrors(null); + assertThat(first.equals(second)).isFalse(); + assertThat(first.hashCode()).isNotEqualTo(second.hashCode()); + + // Create another operation with the same failure count but a different + // request. + BulkRequest newOperation = new BulkRequest( + BulkOperation.post("/Users", new UserResource().setUserName("Alice"))); + newOperation.setFailOnErrors(2); + assertThat(first.equals(newOperation)).isFalse(); + } + + /** + * Construct an example bulk request that utilizes a bulk prefix to reference + * another operation. + */ + @Test + public void testBulkPrefix() + { + // Assemble the bulk request from RFC 7644 Section 3.7.2. + GroupResource group = new GroupResource() + .setDisplayName("Tour Guides") + .setMembers( + new Member() + .setType("User") + .setValue(BULK_PREFIX + "qwerty") + ); + BulkRequest request = new BulkRequest( + BulkOperation.post("/Users", new UserResource().setUserName("Alice")) + .setBulkId("qwerty"), + BulkOperation.post("/Groups", group).setBulkId("ytreq") + ); + + // Fetch the operation. + BulkOperation op = request.getOperations().get(1); + assertThat(op.getBulkId()).isEqualTo("ytreq"); + + // Decode the 'data' field. + ScimResource resource = op.getDataAsScimResource(); + if (!(resource instanceof GroupResource decodedGroup)) + { + fail("The operation was not a group resource."); + return; + } + + assertThat(decodedGroup.getMembers()).hasSize(1); + assertThat(decodedGroup.getMembers().get(0).getValue()) + .isEqualTo("bulkId:qwerty"); + } + + /** + * Validate the {@link BulkRequest#replaceBulkIdValues} method. This test + * simulates a scenario where a bulk request would overwrite bulk ID values + * while iterating over the request. In other words, it verifies that the + * method may be called on an object within a for-loop that iterates over the + * same object. + */ + @Test + public void testReplacingBulkIds() + { + BulkOperation user1 = BulkOperation.post("/Users", + new UserResource().setUserName("breathe")).setBulkId("qwerty"); + BulkOperation group1 = BulkOperation.post("/Groups", + new GroupResource().setDisplayName("Avoidance").setMembers( + new Member().setValue(BULK_PREFIX + "qwerty"), + new Member().setValue(BULK_PREFIX + "ytrewq")) + ); + BulkOperation user2 = BulkOperation.post("/Users", + new UserResource().setUserName("speak")).setBulkId("ytrewq"); + + // Create the initial bulk request that will be tested. + final BulkRequest request = new BulkRequest(user1, group1, user2); + + List userIDs = new ArrayList<>(); + for (BulkOperation op : request) + { + ScimResource resource = op.getDataAsScimResource(); + if (resource instanceof UserResource userRequest) + { + UserResource user = processUserCreate(userRequest); + assertThat(user.getId()).isNotNull(); + userIDs.add(user.getId()); + + // Once the user is created, replace its bulk references in the request. + // This should not cause an error even though we are in the middle of + // iterating over this bulk request object. + String bulkId = op.getBulkId(); + assertThat(bulkId).isNotNull(); + assertThatCode(() -> request.replaceBulkIdValues(bulkId, user.getId())) + .doesNotThrowAnyException(); + } + else if (resource instanceof GroupResource) + { + // During the first iteration, there should still be an unresolved bulk + // ID since a user with a bulk ID comes after this operation. + assertThat(op.toString()).contains("bulkId:ytrewq"); + assertThat(request.toString()).contains("bulkId:ytrewq"); + } + else + { + assertThat(resource).isNotNull(); + fail("Unexpected resource type found: " + resource.getClass()); + } + } + + assertThat(userIDs).hasSize(2); + + // After the first pass through the bulk request, there should be no more + // bulk ID references on the group BulkOperation. It should now be in a + // state where the bulk operation can be processed. + assertThat(request.toString()).doesNotContain(BULK_PREFIX); + var g = request.getOperations().get(1).getDataAsScimResource(); + assertThat(g).isInstanceOfSatisfying(GroupResource.class, group -> + assertThat(group.getMembers()).containsExactlyInAnyOrder( + new Member().setValue(userIDs.get(0)), + new Member().setValue(userIDs.get(1))) + ); + } + + /** + * Helper method that mocks the creation of a user in a database. + */ + private UserResource processUserCreate(UserResource user) + { + user.setId(UUID.randomUUID().toString()); + return user; + } +} diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkResponseTest.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkResponseTest.java new file mode 100644 index 00000000..d6151e5f --- /dev/null +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkResponseTest.java @@ -0,0 +1,186 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.common.bulk; + +import com.unboundid.scim2.common.utils.JsonUtils; +import org.testng.annotations.Test; + +import java.util.Arrays; +import java.util.List; + +import static com.unboundid.scim2.common.bulk.BulkOperationResult.HTTP_STATUS_CREATED; +import static com.unboundid.scim2.common.bulk.BulkOperationResult.HTTP_STATUS_NO_CONTENT; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for the {@link BulkResponse} class. + */ +public class BulkResponseTest +{ + /** + * Validation for BulkResponse constructors and general usage. + */ + @Test + public void testBasic() + { + // The list constructor should treat null as an empty list. + assertThat(new BulkResponse(null).getOperations()) + .isNotNull() + .isEmpty(); + + BulkOperationResult result = new BulkOperationResult(BulkOpType.POST, + HTTP_STATUS_CREATED, + "https://example.com/v2/Users/92b725cd", + null, + "qwerty", + "W/\"4weymrEsh5O6cAEK\""); + + BulkOperationResult result2 = new BulkOperationResult(BulkOpType.POST, + HTTP_STATUS_CREATED, + "https://example.com/v2/Groups/e9e30dba", + null, + "ytrewq", + "W/\"lha5bbazU3fNvfe5\""); + + // The list constructor should accept single-valued lists. + assertThat(new BulkResponse(List.of(result)).getOperations()) + .hasSize(1) + .first().isEqualTo(result); + + // The list constructor should filter null values. + assertThat(new BulkResponse(Arrays.asList(result, null)).getOperations()) + .hasSize(1) + .first().isEqualTo(result); + + // Create a general bulk response with two results. The returned list should + // always be immutable. + BulkResponse response = new BulkResponse(List.of(result, result2)); + assertThat(response.getOperations()) + .isUnmodifiable() + .hasSize(2) + .containsExactly(result, result2); + + // The alternate constructor should be able to create an equivalent bulk + // response in a succinct manner. + assertThat(new BulkResponse(result, result2)).isEqualTo(response); + + // It should be possible to iterate over the bulk response in an enhanced + // for-loop. + for (BulkOperationResult r : response) + { + assertThat(r.getMethod()).isEqualTo(BulkOpType.POST); + } + } + + /** + * Ensures bulk responses can be serialized and deserialized successfully. + */ + @Test + public void testSerialization() throws Exception + { + String rawJSONString = """ + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:BulkResponse"], + "Operations": [ + { + "location": "https://example.com/v2/Users/92b725cd", + "method": "POST", + "bulkId": "qwerty", + "version": "W/\\"4weymrEsh5O6cAEK\\"", + "status": "201" + }, + { + "location": "https://example.com/v2/Groups/e9e30dba", + "method": "POST", + "bulkId": "ytrewq", + "version": "W/\\"lha5bbazU3fNvfe5\\"", + "status": "201" + } + ] + }"""; + + // Reformat the string in a standardized form. + final String jsonResponse = JsonUtils.getObjectReader() + .readTree(rawJSONString).toString(); + + // Create the same object as a POJO. + BulkResponse pojoResponse = new BulkResponse( + new BulkOperationResult(BulkOpType.POST, + HTTP_STATUS_CREATED, + "https://example.com/v2/Users/92b725cd", + null, + "qwerty", + "W/\"4weymrEsh5O6cAEK\""), + new BulkOperationResult(BulkOpType.POST, + HTTP_STATUS_CREATED, + "https://example.com/v2/Groups/e9e30dba", + null, + "ytrewq", + "W/\"lha5bbazU3fNvfe5\"") + ); + + // Ensure serializing an object into JSON matches the expected form. + var serial = JsonUtils.getObjectWriter().writeValueAsString(pojoResponse); + assertThat(serial).isEqualTo(jsonResponse); + + // Ensure deserializing a string into an object is successful. + BulkResponse deserialized = JsonUtils.getObjectReader() + .forType(BulkResponse.class).readValue(jsonResponse); + assertThat(deserialized).isEqualTo(pojoResponse); + } + + /** + * Test {@link BulkResponse#equals(Object)}. + */ + @SuppressWarnings("all") + @Test + public void testEquals() + { + BulkOperationResult result = new BulkOperationResult(BulkOpType.DELETE, + HTTP_STATUS_NO_CONTENT, + "https://example.com/v2/Users/fa1afe1", + null, + "qwerty", + "W/\"4weymrEsh5O6cAEK\""); + + BulkResponse response = new BulkResponse(result); + assertThat(response.equals(response)).isTrue(); + assertThat(response.equals(null)).isFalse(); + + // Bulk responses should not be equivalent if the operations do not match. + // The hash code should also be different. + BulkResponse emptyResponse = new BulkResponse(List.of()); + assertThat(response.equals(emptyResponse)).isFalse(); + assertThat(response.hashCode()).isNotEqualTo(emptyResponse.hashCode()); + } +} diff --git a/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/BulkEndpoint.java b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/BulkEndpoint.java new file mode 100644 index 00000000..c19a2e04 --- /dev/null +++ b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/BulkEndpoint.java @@ -0,0 +1,183 @@ +/* + * Copyright 2026 Ping Identity Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* + * Copyright 2026 Ping Identity Corporation + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License (GPLv2 only) + * or the terms of the GNU Lesser General Public License (LGPLv2.1 only) + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +package com.unboundid.scim2.server; + + +import com.unboundid.scim2.common.annotations.NotNull; +import com.unboundid.scim2.common.bulk.BulkOpType; +import com.unboundid.scim2.common.bulk.BulkOperation; +import com.unboundid.scim2.common.bulk.BulkOperationResult; +import com.unboundid.scim2.common.bulk.BulkRequest; +import com.unboundid.scim2.common.bulk.BulkResponse; +import com.unboundid.scim2.common.exceptions.BadRequestException; +import com.unboundid.scim2.server.annotations.ResourceType; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static com.unboundid.scim2.common.utils.ApiConstants.MEDIA_TYPE_SCIM; + + +/** + * A test implementation of a Bulk endpoint that returns successful responses + * for each operation. + */ +@ResourceType( + description = "Bulk Request Endpoint", + name = "Bulk Request Endpoint", + schema = BulkResponse.class) +@Path("/Bulk") +public class BulkEndpoint +{ + // A UUID for "location" values on create operations. This is stored as a + // constant so that multiple POST operations within a single test will result + // in a consistent value, simplifying comparison logic. + private static final UUID CREATED_RESOURCE_ID = UUID.randomUUID(); + + + /** + * This endpoint simulates successful responses from bulk requests. The + * {@code response} and {@code version} fields will always be {@code null}. + * + * @param request The bulk request. + * @return A successful bulk response. + */ + @POST + @Produces({MEDIA_TYPE_SCIM, MediaType.APPLICATION_JSON}) + public BulkResponse processBulkRequest(@NotNull final BulkRequest request) + { + List results = new ArrayList<>(); + for (BulkOperation op : request) + { + String endpoint = op.getPath(); + String status = "200"; + if (op.getMethod() == BulkOpType.POST) + { + status = "201"; + endpoint += "/" + CREATED_RESOURCE_ID; + } + else if (op.getMethod() == BulkOpType.DELETE) + { + status = "204"; + } + + BulkOperationResult result = new BulkOperationResult(op, status, + "https://example.com/v2" + endpoint); + results.add(result); + } + + return new BulkResponse(results); + } + + /** + * Returns a bulk response with several nested objects within it. + * + * @return A bulk response. + */ + @POST + @Path("testBulkRequest") + @Produces({MEDIA_TYPE_SCIM, MediaType.APPLICATION_JSON}) + public Response testBulkRequest() + { + // Return a UserResource, a GroupResource, an error, and a SCIM enterprise + // user that does not have an object model. The last resource should be + // deserialized as a GenericScimResource. + return Response.status(Response.Status.OK) + .type(MEDIA_TYPE_SCIM) + .entity(""" + { + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:BulkResponse" + ], + "Operations": [ { + "location": "https://example.com/v2/Users/fa1afe1", + "method": "POST", + "response": { + "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], + "userName": "silhouette.man" + }, + "status": "201" + }, { + "location": "https://example.com/v2/Groups/c0a1e5ce", + "method": "PUT", + "response": { + "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:Group" ], + "displayName": "hotel.california" + }, + "status": "200" + }, { + "location" : "https://example.com/v2/Users/deadbeef", + "method" : "DELETE", + "response" : { + "schemas" : [ "urn:ietf:params:scim:api:messages:2.0:Error" ], + "detail" : "The requested resource was not found.", + "status" : "404" + }, + "status" : "404" + }, { + "location": "https://example.com/v2/CustomResource/af10a7", + "method": "PATCH", + "response": { + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" + ], + "externalId": "tarmac", + "userName": "tarmac" + }, + "status": "200" + } ] + }""").build(); + } + + /** + * This endpoint simulates an error when initiating a bulk request. + * + * @param ignored The client bulk request. + * @return This method never returns since it always throws an exception. + */ + @POST + @Path("/BulkError") + public BulkResponse error(@NotNull final BulkRequest ignored) + throws BadRequestException + { + throw BadRequestException.tooMany( + "Simulated error for too many bulk operations."); + } +} diff --git a/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/EndpointTestCase.java b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/EndpointTestCase.java index 95affc72..3adbbdbd 100644 --- a/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/EndpointTestCase.java +++ b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/EndpointTestCase.java @@ -42,6 +42,10 @@ import com.unboundid.scim2.common.GenericScimResource; import com.unboundid.scim2.common.Path; import com.unboundid.scim2.common.ScimResource; +import com.unboundid.scim2.common.bulk.BulkOpType; +import com.unboundid.scim2.common.bulk.BulkOperation; +import com.unboundid.scim2.common.bulk.BulkOperationResult; +import com.unboundid.scim2.common.bulk.BulkResponse; import com.unboundid.scim2.common.exceptions.BadRequestException; import com.unboundid.scim2.common.exceptions.MethodNotAllowedException; import com.unboundid.scim2.common.exceptions.ResourceNotFoundException; @@ -55,6 +59,7 @@ import com.unboundid.scim2.common.messages.SortOrder; import com.unboundid.scim2.common.types.Email; import com.unboundid.scim2.common.types.EnterpriseUserExtension; +import com.unboundid.scim2.common.types.GroupResource; import com.unboundid.scim2.common.types.Meta; import com.unboundid.scim2.common.types.Name; import com.unboundid.scim2.common.types.PhoneNumber; @@ -144,6 +149,7 @@ protected Application configure() config.register(requestFilter); // Standard endpoints + config.register(BulkEndpoint.class); config.register(ResourceTypesEndpoint.class); config.register(CustomContentEndpoint.class); config.register(SchemasEndpoint.class); @@ -189,7 +195,6 @@ public void setUp() throws Exception Collections.singletonList(new ResourceTypeResource.SchemaExtension( new URI(enterpriseSchema.getId()), true))); setMeta(ResourceTypesEndpoint.class, singletonResourceType); - serviceProviderConfig = TestServiceProviderConfigEndpoint.create(); setMeta(TestServiceProviderConfigEndpoint.class, serviceProviderConfig); } @@ -268,9 +273,10 @@ public void testGetSchemas() throws ScimException final ListResponse returnedSchemas = new ScimService(target()).getSchemas(); - assertEquals(returnedSchemas.getTotalResults(), 2); - assertTrue(contains(returnedSchemas, userSchema)); - assertTrue(contains(returnedSchemas, enterpriseSchema)); + assertThat(returnedSchemas.getTotalResults()).isEqualTo(3); + assertThat(returnedSchemas) + .contains(userSchema) + .contains(enterpriseSchema); // Now with application/json WebTarget target = target().register( @@ -324,9 +330,10 @@ public void testGetResourceTypes() throws ScimException final ListResponse returnedResourceTypes = new ScimService(target()).getResourceTypes(); - assertEquals(returnedResourceTypes.getTotalResults(), 3); - assertTrue(contains(returnedResourceTypes, resourceType)); - assertTrue(contains(returnedResourceTypes, singletonResourceType)); + assertThat(returnedResourceTypes.getTotalResults()).isEqualTo(4); + assertThat(returnedResourceTypes) + .contains(resourceType) + .contains(singletonResourceType); // Now with application/json WebTarget target = target().register( @@ -1368,6 +1375,181 @@ public void testAcceptExceptions() } + /** + * This test performs basic validation for bulk requests and responses in a + * Jakarta-RS environment. Handling for these {@code /Bulk} calls come from + * {@link BulkEndpoint#processBulkRequest}. + */ + @Test + public void testBulk() throws Exception + { + ScimService scimService = new ScimService(target()); + final BulkOperation post = BulkOperation.post("/Users", + new UserResource().setUserName("frieren")); + final BulkOperation put = BulkOperation.put("/Users/fa1afe1", + new UserResource().setUserName("frieren")); + final BulkOperation patch = BulkOperation.patch("/Users/fa1afe1", + PatchOperation.remove("nickName")); + final BulkOperation delete = BulkOperation.delete("/Users/fa1afe1"); + + // Validate a single delete operation. + BulkResponse response = scimService.bulkRequest() + .append(delete) + .invoke(); + assertThat(response.getOperations()).hasSize(1); + BulkOperationResult deleteResult = response.getOperations().get(0); + assertThat(deleteResult.getMethod()).isEqualTo(BulkOpType.DELETE); + assertThat(deleteResult.getStatus()).isEqualTo("204"); + assertThat(deleteResult.getLocation()).endsWith("/Users/fa1afe1"); + + // A single create operation. + BulkResponse createResponse = scimService.bulkRequest() + .append(post) + .invoke(); + assertThat(createResponse.getOperations()).hasSize(1); + BulkOperationResult createResult = response.getOperations().get(0); + assertThat(createResult.getMethod()).isEqualTo(BulkOpType.DELETE); + assertThat(createResult.getStatus()).isEqualTo("204"); + assertThat(createResult.getLocation()).endsWith("/Users/fa1afe1"); + + // A single PUT operation. + BulkResponse replaceResponse = scimService.bulkRequest() + .append(put) + .invoke(); + assertThat(replaceResponse.getOperations()).hasSize(1); + BulkOperationResult replaceResult = response.getOperations().get(0); + assertThat(replaceResult.getMethod()).isEqualTo(BulkOpType.DELETE); + assertThat(replaceResult.getStatus()).isEqualTo("204"); + assertThat(replaceResult.getLocation()).endsWith("/Users/fa1afe1"); + + // A single PATCH operation. + BulkResponse modifyResponse = scimService.bulkRequest() + .append(patch) + .invoke(); + assertThat(modifyResponse.getOperations()).hasSize(1); + BulkOperationResult modifyResult = response.getOperations().get(0); + assertThat(modifyResult.getMethod()).isEqualTo(BulkOpType.DELETE); + assertThat(modifyResult.getStatus()).isEqualTo("204"); + assertThat(modifyResult.getLocation()).endsWith("/Users/fa1afe1"); + + // Issue requests with no operations. + BulkResponse noOpResponse = scimService.bulkRequest().invoke(); + assertThat(noOpResponse.getOperations()).hasSize(0); + noOpResponse = scimService.bulkRequest() + .append((List) null) + .append((BulkOperation[]) null) + .append(null, null) + .append() + .invoke(); + assertThat(noOpResponse.getOperations()).hasSize(0); + + // Issue a request with multiple operations. + BulkOperation postWithBulkID = post.copy().setBulkId("Bulkley"); + BulkResponse multipleOpResponse = scimService.bulkRequest() + .append(postWithBulkID, put, patch, delete) + .invoke(); + assertThat(multipleOpResponse.getOperations()).hasSize(4); + + // Ensure the order of the requests is preserved. + List expectedList = List.of( + new BulkOperationResult( + BulkOpType.POST, "201", "location", null, "Bulkley", null), + new BulkOperationResult( + BulkOpType.PUT, "200", "location", null, null, null), + new BulkOperationResult( + BulkOpType.PATCH, "200", "location", null, null, null), + new BulkOperationResult( + BulkOpType.DELETE, "204", "location", null, null, null) + ); + for (int i = 0; i < expectedList.size(); i++) + { + BulkOperationResult expected = expectedList.get(i); + BulkOperationResult actual = multipleOpResponse.getOperations().get(i); + + assertThat(actual.getMethod()).isEqualTo(expected.getMethod()); + assertThat(actual.getStatus()).isEqualTo(expected.getStatus()); + assertThat(actual.getBulkId()).isEqualTo(expected.getBulkId()); + } + + // Re-run the previous test case with an explicit endpoint provided to the + // bulk request. + var uri = UriBuilder.fromUri(getBaseUri()).path("/Bulk").build(); + BulkResponse multipleOpResponse2 = scimService.bulkRequest(uri) + .append(postWithBulkID, put, patch, delete) + .invoke(); + assertThat(multipleOpResponse2).isEqualTo(multipleOpResponse); + + // Simulate a bulk error. + var errorUri = UriBuilder.fromUri(getBaseUri()) + .path("/Bulk").path("/BulkError").build(); + assertThatThrownBy(() -> scimService.bulkRequest(errorUri).invoke()) + .isInstanceOf(BadRequestException.class) + .hasMessage("Simulated error for too many bulk operations."); + } + + + /** + * Test the behavior of a bulk response processed by the SCIM SDK into an + * object. In particular, objects within the bulk response should be + * deserialized properly if they are registered with the + * {@link com.unboundid.scim2.common.bulk.BulkResourceMapper}. + *

+ * + * The JSON responses obtained by this test are defined in + * {@link BulkEndpoint#testBulkRequest}. + */ + @Test + public void testBulkRequestJsonProcessing() throws Exception + { + final ScimService service = new ScimService(target()); + + // Invoke a bulk request and obtain a normal response. + BulkResponse response = + service.bulkRequest("/Bulk/testBulkRequest").invoke(); + assertThat(response.getSchemaUrns()) + .hasSize(1) + .containsOnly("urn:ietf:params:scim:api:messages:2.0:BulkResponse"); + final List resultList = response.getOperations(); + assertThat(resultList).hasSize(4); + + // Validate each bulk operation result that was returned. + assertThat(resultList.get(0)).satisfies(boResult -> { + assertThat(boResult.getStatus()).isEqualTo("201"); + assertThat(boResult.isSuccess()).isTrue(); + + ScimResource u = boResult.getResponseAsScimResource(); + assertThat(u).isInstanceOfSatisfying(UserResource.class, + user -> assertThat(user.getUserName()).isEqualTo("silhouette.man")); + }); + + assertThat(resultList.get(1)).satisfies(boResult -> { + assertThat(boResult.getStatus()).isEqualTo("200"); + assertThat(boResult.isSuccess()).isTrue(); + + ScimResource g = boResult.getResponseAsScimResource(); + assertThat(g).isInstanceOfSatisfying(GroupResource.class, + group -> assertThat(group.getDisplayName()).contains("california")); + }); + + assertThat(resultList.get(2)).satisfies(boResult -> { + assertThat(boResult.getStatus()).isEqualTo("404"); + assertThat(boResult.isClientError()).isTrue(); + + ScimResource e = boResult.getResponseAsScimResource(); + assertThat(e).isInstanceOfSatisfying(ErrorResponse.class, + error -> assertThat(error.getDetail()).contains("was not found")); + }); + + assertThat(resultList.get(3)).satisfies(boResult -> { + assertThat(boResult.getStatus()).isEqualTo("200"); + + ScimResource g = boResult.getResponseAsScimResource(); + assertThat(g).isInstanceOfSatisfying(GenericScimResource.class, + gsr -> assertThat(gsr.getExternalId()).isEqualTo("tarmac")); + }); + } + + private void setMeta(Class resourceClass, ScimResource scimResource) { ResourceTypeResource resourceType = From 69b6c2d616500e453c926ed62bac11519817f045 Mon Sep 17 00:00:00 2001 From: Khalid Qarryzada Date: Wed, 1 Apr 2026 07:44:36 -0700 Subject: [PATCH 2/5] Doc updates and fix status attr order --- .../common/bulk/BulkOperationResult.java | 41 +++++++++---------- .../scim2/common/bulk/BulkRequest.java | 26 ++++++------ .../scim2/common/bulk/BulkResourceMapper.java | 4 +- .../scim2/common/bulk/BulkResponse.java | 4 +- .../common/bulk/BulkOperationResultTest.java | 2 +- .../unboundid/scim2/server/BulkEndpoint.java | 28 ++++++------- .../scim2/server/EndpointTestCase.java | 9 ++++ 7 files changed, 61 insertions(+), 53 deletions(-) diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperationResult.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperationResult.java index 5b1a512a..24fa14ed 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperationResult.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperationResult.java @@ -101,12 +101,12 @@ * "location": "https://example.com/v2/Users/fa1afe1", * "method": "POST", * "bulkId": "originalBulkId", + * "status": "200", * "response": { * "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], * "id": "fa1afe1", * "userName": "walker" - * }, - * "status": "200" + * } * } * * @@ -140,13 +140,13 @@ *
  * {
  *   "method": "POST",
+ *   "status": "400",
  *   "response": {
  *     "schemas": [ "urn:ietf:params:scim:api:messages:2.0:Error" ],
  *     "scimType": "invalidSyntax",
  *     "detail": "Request is unparsable or violates the schema.",
  *     "status": "400"
- *   },
- *   "status": "400"
+ *   }
  * }
  * 
* @@ -170,8 +170,8 @@ * @since 5.1.0 */ @SuppressWarnings("JavadocLinkAsPlainText") -@JsonPropertyOrder({ "location", "method", "bulkId", "version", "response", - "status" }) +@JsonPropertyOrder({ "location", "method", "bulkId", "version", "status", + "response" }) public class BulkOperationResult { /** @@ -204,8 +204,7 @@ public class BulkOperationResult private final BulkOpType method; /** - * The bulk ID of the original bulk operation. For more information, see - * {@link BulkOperation#bulkId}. + * The bulk ID of the original bulk operation. */ @Nullable private String bulkId; @@ -217,6 +216,19 @@ public class BulkOperationResult @Nullable private String version; + /** + * The status code to return for the write request, e.g., {@code "200"} + * representing a {@code 200 OK} response for a successful modification. + */ + @NotNull + @JsonDeserialize(using = BulkStatusDeserializer.class) + private String status = ""; + + /** + * The status value as an integer. + */ + private int statusInt; + /** * The JSON data representing the response for the individual write request. * The form of this value depends on the nature of the bulk operation result: @@ -232,19 +244,6 @@ public class BulkOperationResult @Nullable private ObjectNode response; - /** - * The status code to return for the write request, e.g., {@code "200"} - * representing a {@code 200 OK} response for a successful modification. - */ - @NotNull - @JsonDeserialize(using = BulkStatusDeserializer.class) - private String status = ""; - - /** - * The status value as an integer. - */ - private int statusInt; - /** * Constructs an entry to be contained in a {@link BulkResponse} based on the * initial bulk operation that requested the update. diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkRequest.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkRequest.java index 7b94b8eb..63cbba56 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkRequest.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkRequest.java @@ -63,8 +63,8 @@ * *

SCIM 2 Bulk Requests and Responses

* - * Issuing a REST API call always incurs request processing overhead, namely - * DNS resolution, TCP handshakes and termination, and TLS negotiation. This is + * Issuing a REST API call always incurs network processing overhead, namely DNS + * resolution, TCP handshakes and termination, and TLS negotiation. This is * necessary work for all API calls, but can become expensive in cases where a * client anticipates sending large amounts of traffic (e.g., on the order of * millions of requests). A SCIM bulk request is an optimization that combines @@ -139,16 +139,17 @@ * "schemas": [ "urn:ietf:params:scim:api:messages:2.0:BulkResponse" ], * "Operations": [ { * "method": "POST", + * "status": "400", * "response": { * "schemas": [ "urn:ietf:params:scim:api:messages:2.0:Error" ], * "scimType": "uniqueness", * "detail": "The requested username was not available.", * "status": "400" - * }, - * "status": "400" + * } * }, { * "location": "https://example.com/v2/Users/5b261bdf", * "method": "POST", + * "status": "201", * "response": { * "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], * "userName": "Whiplash!" @@ -156,8 +157,7 @@ * "meta": { * "created": "1970-01-01T13:00:00Z" * }, - * }, - * "status": "201" + * } * } ] * } * @@ -191,7 +191,7 @@ * new BulkOperationResult(e, BulkOpType.POST, null); * * final BulkOperationResult successResult = new BulkOperationResult( - * BulkOpType.POST, + * getClientBulkOperation(), * BulkOperationResult.HTTP_STATUS_CREATED, * "https://example.com/v2/Users/5b261bdf"); * // Set the JSON payload value. @@ -227,9 +227,9 @@ * With these methods, along with the {@code instanceof} keyword, application * code can easily and directly handle object representations of SCIM resources. * As an example, a service handling client bulk requests may use the following - * structure. This example does not handle client bulk ID values (see - * {@link BulkOperation}), and it is intentionally verbose to indicate cases that - * can occur for all types of requests and responses. + * structure. This example does not handle client bulk ID values (see more info + * below), and it is intentionally verbose to indicate cases that can occur for + * all types of requests and responses. *

  *   public BulkResponse processBulkRequest(BulkRequest bulkRequest)
  *   {
@@ -396,7 +396,7 @@
  *
  * 

More About Bulk Requests

* - * Some additional background about bulk requests and how SCIM services may + * Some additional background about bulk requests and how SCIM applications may * process them are shared below. *
    *
  • Bulk responses can return fewer results than what the client bulk @@ -422,8 +422,8 @@ *
  • By their nature, bulk requests can quickly become expensive traffic * for a SCIM service to process. As a client, it is important to be * careful about restrictions such as payload sizes and rate limits when - * sending bulk requests to a SCIM service. Be prepared to handle - * {@link ContentTooLargeException} and {@link RateLimitException} errors. + * sending bulk requests to a SCIM service. Be prepared to handle errors + * like {@link ContentTooLargeException} and {@link RateLimitException}. *
  • As a SCIM service, it is generally a good idea to implement request * limiting on endpoints that facilitate bulk request processing, as * these endpoints can be a potential attack vector. This is crucial for diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkResourceMapper.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkResourceMapper.java index 7d6e1267..8c03698e 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkResourceMapper.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkResourceMapper.java @@ -68,12 +68,12 @@ * { * "location": "/Users/fa1afe1", * "method": "POST", + * "status": "200", * "response": { * "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], * "id": "fa1afe1", * "userName": "Polaroid" - * }, - * "status": "200" + * } * } *
*

diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkResponse.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkResponse.java index 72d2ebb8..975e0f9d 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkResponse.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkResponse.java @@ -69,11 +69,11 @@ * "location": "https://example.com/v2/Users/fa1afe1", * "method": "POST", * "bulkId": "qwerty", + * "status": "201", * "response": { * "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], * "userName": "Alice" * } - * "status": "201" * } * ] * } @@ -82,7 +82,7 @@ * The bulk response indicates that the server-side processing successfully * created one user resource. It also details the URI for the new resource, * along with the original HTTP method, the bulk ID set in the client bulk - * request, the optional JSON response data, and the HTTP response status code. + * request, the HTTP response status code, and the optional JSON response data. *

* * This bulk response can be created with the following Java code. See diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkOperationResultTest.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkOperationResultTest.java index 4ea10bde..a9511221 100644 --- a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkOperationResultTest.java +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkOperationResultTest.java @@ -345,7 +345,7 @@ public void testSettingNull() { // Check the constructors for null operations and statuses. assertThatThrownBy(() -> - new BulkOperationResult((BulkOperation) null, "500", location)) + new BulkOperationResult(null, "500", location)) .isInstanceOf(NullPointerException.class); assertThatThrownBy(() -> new BulkOperationResult(BulkOperation.delete("/val"), null, location)) diff --git a/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/BulkEndpoint.java b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/BulkEndpoint.java index c19a2e04..03a03307 100644 --- a/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/BulkEndpoint.java +++ b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/BulkEndpoint.java @@ -128,31 +128,32 @@ public Response testBulkRequest() "Operations": [ { "location": "https://example.com/v2/Users/fa1afe1", "method": "POST", + "status": "201", "response": { "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], "userName": "silhouette.man" - }, - "status": "201" + } }, { "location": "https://example.com/v2/Groups/c0a1e5ce", "method": "PUT", + "status": "200", "response": { "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:Group" ], "displayName": "hotel.california" - }, - "status": "200" + } }, { - "location" : "https://example.com/v2/Users/deadbeef", - "method" : "DELETE", - "response" : { - "schemas" : [ "urn:ietf:params:scim:api:messages:2.0:Error" ], - "detail" : "The requested resource was not found.", - "status" : "404" - }, - "status" : "404" + "location": "https://example.com/v2/Users/deadbeef", + "method": "DELETE", + "status": "404", + "response": { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:Error" ], + "detail": "The requested resource was not found.", + "status": "404" + } }, { "location": "https://example.com/v2/CustomResource/af10a7", "method": "PATCH", + "status": "200", "response": { "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User", @@ -160,8 +161,7 @@ public Response testBulkRequest() ], "externalId": "tarmac", "userName": "tarmac" - }, - "status": "200" + } } ] }""").build(); } diff --git a/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/EndpointTestCase.java b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/EndpointTestCase.java index 3adbbdbd..29b67560 100644 --- a/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/EndpointTestCase.java +++ b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/EndpointTestCase.java @@ -1516,6 +1516,8 @@ public void testBulkRequestJsonProcessing() throws Exception assertThat(resultList.get(0)).satisfies(boResult -> { assertThat(boResult.getStatus()).isEqualTo("201"); assertThat(boResult.isSuccess()).isTrue(); + assertThat(boResult.isClientError()).isFalse(); + assertThat(boResult.isServerError()).isFalse(); ScimResource u = boResult.getResponseAsScimResource(); assertThat(u).isInstanceOfSatisfying(UserResource.class, @@ -1525,6 +1527,8 @@ public void testBulkRequestJsonProcessing() throws Exception assertThat(resultList.get(1)).satisfies(boResult -> { assertThat(boResult.getStatus()).isEqualTo("200"); assertThat(boResult.isSuccess()).isTrue(); + assertThat(boResult.isClientError()).isFalse(); + assertThat(boResult.isServerError()).isFalse(); ScimResource g = boResult.getResponseAsScimResource(); assertThat(g).isInstanceOfSatisfying(GroupResource.class, @@ -1534,6 +1538,8 @@ public void testBulkRequestJsonProcessing() throws Exception assertThat(resultList.get(2)).satisfies(boResult -> { assertThat(boResult.getStatus()).isEqualTo("404"); assertThat(boResult.isClientError()).isTrue(); + assertThat(boResult.isSuccess()).isFalse(); + assertThat(boResult.isServerError()).isFalse(); ScimResource e = boResult.getResponseAsScimResource(); assertThat(e).isInstanceOfSatisfying(ErrorResponse.class, @@ -1542,6 +1548,9 @@ public void testBulkRequestJsonProcessing() throws Exception assertThat(resultList.get(3)).satisfies(boResult -> { assertThat(boResult.getStatus()).isEqualTo("200"); + assertThat(boResult.isSuccess()).isTrue(); + assertThat(boResult.isClientError()).isFalse(); + assertThat(boResult.isServerError()).isFalse(); ScimResource g = boResult.getResponseAsScimResource(); assertThat(g).isInstanceOfSatisfying(GenericScimResource.class, From ee08f680b3441a5b7794ecc984580af307d1b1f6 Mon Sep 17 00:00:00 2001 From: Khalid Qarryzada Date: Mon, 6 Apr 2026 09:59:22 -0700 Subject: [PATCH 3/5] Address review feedback. --- .../scim2/common/bulk/BulkOperation.java | 28 ++++++------------ .../common/bulk/BulkOperationResult.java | 29 +++++-------------- .../scim2/common/bulk/BulkRequest.java | 13 ++++----- .../scim2/common/bulk/BulkResponse.java | 2 +- .../scim2/common/utils/JsonUtils.java | 19 ++++++------ .../common/bulk/BulkOperationResultTest.java | 4 +-- .../scim2/common/bulk/BulkOperationTest.java | 4 +-- .../scim2/common/bulk/BulkResponseTest.java | 29 ++++++++++++++++--- 8 files changed, 62 insertions(+), 66 deletions(-) diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperation.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperation.java index e9f7bd71..d9fd83b7 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperation.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperation.java @@ -413,7 +413,7 @@ public ScimResource getDataAsScimResource() catch (IllegalArgumentException e) { throw new BulkRequestException( - "A malformed JSON failed to be converted into a SCIM resource.", e); + "Failed to convert a malformed JSON into a SCIM resource.", e); } } @@ -450,7 +450,9 @@ protected List getPatchOperationList() * This method permanently alters the bulk operation directly, and it is not * thread safe. * - * @param bulkId The temporary bulk ID value. + * @param bulkId The temporary bulk ID value. For example, to replace all + * instances of {@code "bulkId:hallmarkCards"}, this string + * should be set to {@code "hallmarkCards"}. * @param realValue The real value after the resource has been created. */ public void replaceBulkIdValue(@NotNull final String bulkId, @@ -902,23 +904,11 @@ public boolean equals(@Nullable final Object o) return false; } - if (!Objects.equals(method, that.method)) - { - return false; - } - if (!Objects.equals(path, that.path)) - { - return false; - } - if (!Objects.equals(bulkId, that.bulkId)) - { - return false; - } - if (!Objects.equals(version, that.version)) - { - return false; - } - return Objects.equals(data, that.data); + return Objects.equals(method, that.method) + && Objects.equals(path, that.path) + && Objects.equals(bulkId, that.bulkId) + && Objects.equals(version, that.version) + && Objects.equals(data, that.data); } /** diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperationResult.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperationResult.java index 24fa14ed..93918641 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperationResult.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperationResult.java @@ -573,7 +573,7 @@ public ScimResource getResponseAsScimResource() catch (IllegalArgumentException e) { throw new BulkResponseException( - "A malformed JSON failed to be converted into a SCIM resource.", e); + "Failed to convert a malformed JSON into a SCIM resource.", e); } } @@ -690,27 +690,12 @@ public boolean equals(@Nullable final Object o) return false; } - if (!Objects.equals(location, that.location)) - { - return false; - } - if (!method.equals(that.method)) - { - return false; - } - if (!Objects.equals(bulkId, that.bulkId)) - { - return false; - } - if (!Objects.equals(version, that.version)) - { - return false; - } - if (!status.equals(that.status)) - { - return false; - } - return Objects.equals(response, that.response); + return Objects.equals(location, that.location) + && method.equals(that.method) + && Objects.equals(bulkId, that.bulkId) + && Objects.equals(version, that.version) + && status.equals(that.status) + && Objects.equals(response, that.response); } /** diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkRequest.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkRequest.java index 63cbba56..9d40bdec 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkRequest.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkRequest.java @@ -623,7 +623,9 @@ public List getOperations() * This method permanently alters the bulk request directly, and it is not * thread safe. * - * @param bulkId The temporary bulk ID value. + * @param bulkId The temporary bulk ID value. For example, to replace all + * instances of {@code "bulkId:hallmarkCards"}, this string + * should be set to {@code "hallmarkCards"}. * @param realValue The real value after the resource has been created. */ public void replaceBulkIdValues(@NotNull final String bulkId, @@ -636,7 +638,7 @@ public void replaceBulkIdValues(@NotNull final String bulkId, } /** - * {@inheritDoc} + * Returns an iterator over operations contained in this bulk request. */ @Override @NotNull @@ -664,11 +666,8 @@ public boolean equals(@Nullable final Object o) return false; } - if (!Objects.equals(failOnErrors, that.failOnErrors)) - { - return false; - } - return operations.equals(that.getOperations()); + return Objects.equals(failOnErrors, that.failOnErrors) + && operations.equals(that.getOperations()); } /** diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkResponse.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkResponse.java index 975e0f9d..0a8e83bf 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkResponse.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkResponse.java @@ -184,7 +184,7 @@ public List getOperations() } /** - * {@inheritDoc} + * Returns an iterator over results contained in this bulk response. */ @Override @NotNull diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/JsonUtils.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/JsonUtils.java index 22390753..dec62d46 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/JsonUtils.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/JsonUtils.java @@ -1048,14 +1048,15 @@ else if (child instanceof ObjectNode childObject) /** * Recursively traverses a JsonNode and replaces all text values with the - * appropriate new value. + * appropriate new value. The replacement is performed left to right, e.g., + * replacing "aa" with "b" in the string "aaa" will result in "ba". * * @param node The JsonNode that should be traversed. - * @param original The original string value contained within the JsonNode. + * @param oldValue The original string value contained within the JsonNode. * @param newValue The new value to use as the replacement. */ public static void replaceAllTextValues(@NotNull final JsonNode node, - @NotNull final String original, + @NotNull final String oldValue, @NotNull final String newValue) { if (node instanceof ObjectNode objectNode) @@ -1066,16 +1067,16 @@ public static void replaceAllTextValues(@NotNull final JsonNode node, if (valueNode.isTextual()) { String text = valueNode.asText(); - if (text.contains(original)) + if (text.contains(oldValue)) { // Replace the text node with the updated value - objectNode.put(field.getKey(), text.replace(original, newValue)); + objectNode.put(field.getKey(), text.replace(oldValue, newValue)); } } else if (valueNode.isObject() || valueNode.isArray()) { // Recursively resolve nested values. - replaceAllTextValues(valueNode, original, newValue); + replaceAllTextValues(valueNode, oldValue, newValue); } } } @@ -1087,15 +1088,15 @@ else if (node instanceof ArrayNode array) if (element.isTextual()) { String text = element.asText(); - if (text.contains(original)) + if (text.contains(oldValue)) { - array.set(i, TextNode.valueOf(text.replace(original, newValue))); + array.set(i, TextNode.valueOf(text.replace(oldValue, newValue))); } } else if (element.isObject() || element.isArray()) { // Recursively resolve nested values. - replaceAllTextValues(element, original, newValue); + replaceAllTextValues(element, oldValue, newValue); } } } diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkOperationResultTest.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkOperationResultTest.java index a9511221..43d03cbb 100644 --- a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkOperationResultTest.java +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkOperationResultTest.java @@ -281,8 +281,8 @@ public void testFetchingResponse() result.setResponse(invalidNode); assertThatThrownBy(result::getResponseAsScimResource) .isInstanceOf(BulkResponseException.class) - .hasMessageContaining("A malformed JSON failed to be converted into a") - .hasMessageContaining("SCIM resource."); + .hasMessageContaining("Failed to convert a malformed JSON into") + .hasMessageContaining("a SCIM resource."); // An error result should result in an ErrorResponse object. BulkOperationResult error = new BulkOperationResult( diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkOperationTest.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkOperationTest.java index 9a3c1089..337db35f 100644 --- a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkOperationTest.java +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkOperationTest.java @@ -363,8 +363,8 @@ public void testFetchingData() throws Exception BulkOperation opWithInvalidData = BulkOperation.post("/Users", invalidNode); assertThatThrownBy(opWithInvalidData::getDataAsScimResource) .isInstanceOf(BulkRequestException.class) - .hasMessageContaining("A malformed JSON failed to be converted into a") - .hasMessageContaining(" SCIM resource."); + .hasMessageContaining("Failed to convert a malformed JSON into") + .hasMessageContaining("a SCIM resource."); } /** diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkResponseTest.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkResponseTest.java index d6151e5f..b0de97a0 100644 --- a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkResponseTest.java +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkResponseTest.java @@ -40,6 +40,7 @@ import static com.unboundid.scim2.common.bulk.BulkOperationResult.HTTP_STATUS_CREATED; import static com.unboundid.scim2.common.bulk.BulkOperationResult.HTTP_STATUS_NO_CONTENT; +import static com.unboundid.scim2.common.bulk.BulkOperationResult.HTTP_STATUS_OK; import static org.assertj.core.api.Assertions.assertThat; /** @@ -166,21 +167,41 @@ public void testSerialization() throws Exception @Test public void testEquals() { - BulkOperationResult result = new BulkOperationResult(BulkOpType.DELETE, + BulkOperationResult result1 = new BulkOperationResult(BulkOpType.DELETE, HTTP_STATUS_NO_CONTENT, "https://example.com/v2/Users/fa1afe1", null, - "qwerty", + null, "W/\"4weymrEsh5O6cAEK\""); + BulkOperationResult result2 = new BulkOperationResult(BulkOpType.PUT, + HTTP_STATUS_OK, + "https://example.com/v2/Users/5ca1ab1e", + null, + null, + null); - BulkResponse response = new BulkResponse(result); + // Bulk responses should be equal to themselves, as well as other response + // objects with the same results. + BulkResponse response = new BulkResponse(result1, result2); + assertThat(response == response).isTrue(); assertThat(response.equals(response)).isTrue(); + BulkResponse response2 = new BulkResponse(result1, result2); + assertThat(response == response2).isFalse(); + assertThat(response.equals(response2)).isTrue(); + assertThat(response2.equals(response)).isTrue(); + + // Null references should never be equivalent. assertThat(response.equals(null)).isFalse(); // Bulk responses should not be equivalent if the operations do not match. // The hash code should also be different. - BulkResponse emptyResponse = new BulkResponse(List.of()); + BulkResponse emptyResponse = new BulkResponse(result1); assertThat(response.equals(emptyResponse)).isFalse(); assertThat(response.hashCode()).isNotEqualTo(emptyResponse.hashCode()); + + // Validate the order of results within a bulk response. + BulkResponse differentOrder = new BulkResponse(result2, result1); + assertThat(response.equals(differentOrder)).isFalse(); + assertThat(response.hashCode()).isNotEqualTo(differentOrder.hashCode()); } } From 7dfcce1e94a5d91a3080b1b191192bcb0d1a9bf1 Mon Sep 17 00:00:00 2001 From: Khalid Qarryzada Date: Tue, 7 Apr 2026 08:54:09 -0700 Subject: [PATCH 4/5] Last round of feedback and updates. --- .../scim2/common/bulk/BulkOperation.java | 8 ++-- .../common/bulk/BulkOperationResult.java | 8 ++-- .../scim2/common/bulk/BulkRequest.java | 41 ++++++++++--------- .../scim2/common/bulk/BulkResponse.java | 4 +- .../scim2/common/bulk/BulkOperationTest.java | 1 + 5 files changed, 33 insertions(+), 29 deletions(-) diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperation.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperation.java index d9fd83b7..e9624074 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperation.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperation.java @@ -436,8 +436,8 @@ protected List getPatchOperationList() // This is not a BulkRequestException since it cannot occur during // deserialization. - throw new IllegalStateException("Attempted fetching patch data for a '" - + method + "' bulk operation."); + throw new IllegalStateException( + "Cannot fetch patch data for a '" + method + "' bulk operation."); } /** @@ -875,8 +875,8 @@ public String toString() { try { - var objectWriter = JsonUtils.getObjectWriter(); - return objectWriter.withDefaultPrettyPrinter().writeValueAsString(this); + return JsonUtils.getObjectWriter().withDefaultPrettyPrinter() + .writeValueAsString(this); } catch (JsonProcessingException e) { diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperationResult.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperationResult.java index 93918641..a4d8cdd1 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperationResult.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperationResult.java @@ -340,8 +340,8 @@ public BulkOperationResult(@NotNull final ScimException scimException, this(scimException, operation.getMethod(), location); // Bulk IDs should only be handled for POST requests. - String bid = (method == BulkOpType.POST) ? operation.getBulkId() : null; - setBulkId(bid); + String bulkId = (method == BulkOpType.POST) ? operation.getBulkId() : null; + setBulkId(bulkId); } /** @@ -660,8 +660,8 @@ public String toString() { try { - var objectWriter = JsonUtils.getObjectWriter(); - return objectWriter.withDefaultPrettyPrinter().writeValueAsString(this); + return JsonUtils.getObjectWriter().withDefaultPrettyPrinter() + .writeValueAsString(this); } catch (JsonProcessingException e) { diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkRequest.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkRequest.java index 9d40bdec..177a5fcb 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkRequest.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkRequest.java @@ -64,13 +64,13 @@ *

SCIM 2 Bulk Requests and Responses

* * Issuing a REST API call always incurs network processing overhead, namely DNS - * resolution, TCP handshakes and termination, and TLS negotiation. This is - * necessary work for all API calls, but can become expensive in cases where a - * client anticipates sending large amounts of traffic (e.g., on the order of - * millions of requests). A SCIM bulk request is an optimization that combines - * multiple write requests into a single API call, minimizing the number of - * times that overhead costs are paid by both the client and server. The - * following documentation discusses some key points on utilizing this class + * resolution, TCP handshakes and termination, TLS negotiation, and HTTP header + * handling. This is necessary work for all API calls, but can become expensive + * in cases where a client anticipates sending large amounts of traffic (e.g., + * on the order of millions of requests). A SCIM bulk request is an optimization + * that combines multiple write requests into a single API call, minimizing the + * number of times that overhead costs are paid by both the client and server. + * The following documentation discusses some key points on utilizing this class * to support bulk workflows. For further background, see * * RFC 7644. @@ -169,7 +169,7 @@ * that would be seen from a typical SCIM POST request to the {@code /Users} * endpoint. Note that the SCIM standard states that the {@code response} field * is always defined with information in the event of an error, but successful - * responses may omit the value to reduce the size of the response. + * responses may omit the value to reduce the size of the bulk payload response. *

* * To create the above bulk request and response, the following Java code may @@ -202,6 +202,7 @@ * * BulkResponse response = new BulkResponse(failedResult, successResult); *
+ *

* *

Handling Bulk Request and Response Data

* @@ -228,8 +229,8 @@ * code can easily and directly handle object representations of SCIM resources. * As an example, a service handling client bulk requests may use the following * structure. This example does not handle client bulk ID values (see more info - * below), and it is intentionally verbose to indicate cases that can occur for - * all types of requests and responses. + * below), and is intentionally verbose to indicate cases that can occur for all + * types of requests and responses. *

  *   public BulkResponse processBulkRequest(BulkRequest bulkRequest)
  *   {
@@ -367,9 +368,10 @@
  * used to help with this processing. After an operation (e.g., creating a user)
  * has completed successfully, all other operations that reference the bulk ID
  * can be updated to start using the real {@code id} of the resource once it is
- * available. Note that this directly modifies the operations contained within
- * the bulk request to avoid repeatedly duplicating bulk objects in memory, and
- * this method is not thread safe.
+ * available, as seen in the example code below. Note that this implementation
+ * directly modifies the operations contained within the bulk request to avoid
+ * repeatedly duplicating bulk objects in memory, and this method is not thread
+ * safe.
  * 

  *   BulkOperation op;
  *   BulkOperationResult result;
@@ -382,6 +384,7 @@
  *     bulkRequest.replaceBulkIdValues(bulkId, createdResource.getId());
  *   }
  * 
+ *

* * One complication with bulk IDs is that it can be possible to create circular * dependencies. If a circular reference is contained within a request via @@ -393,6 +396,7 @@ * RFC 7644 Section 3.7.1. Note that if a SCIM service allows creating loops * in the graph representation of their groups, the linked example can be * considered a valid circular dependency. + *

* *

More About Bulk Requests

* @@ -413,12 +417,11 @@ * and the same stateful result is achieved..." *
  • By default, RFC 7644 states that SCIM services "MUST continue * performing as many changes as possible and disregard partial - * failures". In other words, bulk requests must attempt to process as - * many of the bulk operations as possible, even if failures occur for - * some of the bulk operations. As stated above, clients may override - * this behavior by setting the {@code failOnErrors} field. - *
  • Bulk requests are not atomic. Successful updates will always be - * applied until the optional {@code failOnErrors} threshold is reached. + * failures". In other words, services must attempt to process all bulk + * operations within a request, even if some failures occur. As stated + * above, clients may override this behavior with {@code failOnErrors}. + *
  • Bulk requests are not atomic. Clients should be prepared to handle + * cases where a subset (or all) bulk operations failed. *
  • By their nature, bulk requests can quickly become expensive traffic * for a SCIM service to process. As a client, it is important to be * careful about restrictions such as payload sizes and rate limits when diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkResponse.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkResponse.java index 0a8e83bf..37cea23b 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkResponse.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkResponse.java @@ -94,8 +94,8 @@ * "https://example.com/v2/Users/fa1afe1", * null, * null, - * "W/\"4weymrEsh5O6cAEK\""); - * + * "W/\"4weymrEsh5O6cAEK\"" + * ); * result.setResponse(new UserResource().setUserName("Alice")); * BulkResponse response = new BulkResponse(result); *
  • diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkOperationTest.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkOperationTest.java index 337db35f..f5f8cf0c 100644 --- a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkOperationTest.java +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkOperationTest.java @@ -313,6 +313,7 @@ public void testPatchOpList() throws Exception ); BulkOperation bulkPatch = BulkOperation.patch("/Users/userID", patchList); assertThat(bulkPatch.getPatchOperationList()).isEqualTo(patchList); + assertThat(bulkPatch.getPatchOperationList() != patchList).isTrue(); // The method should only be allowed for PATCH operations. ObjectNode node = JsonUtils.getJsonNodeFactory().objectNode(); From 833360b87caedca2f71c9c54f15d6196e23ea793 Mon Sep 17 00:00:00 2001 From: Khalid Qarryzada Date: Tue, 7 Apr 2026 08:57:34 -0700 Subject: [PATCH 5/5] Fix Checkstyle. --- .../com/unboundid/scim2/common/bulk/BulkOperationResult.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperationResult.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperationResult.java index a4d8cdd1..c965778b 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperationResult.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperationResult.java @@ -340,8 +340,8 @@ public BulkOperationResult(@NotNull final ScimException scimException, this(scimException, operation.getMethod(), location); // Bulk IDs should only be handled for POST requests. - String bulkId = (method == BulkOpType.POST) ? operation.getBulkId() : null; - setBulkId(bulkId); + String bulkVal = (method == BulkOpType.POST) ? operation.getBulkId() : null; + setBulkId(bulkVal); } /**