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..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 @@ -32,6 +32,911 @@ 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( + "Failed to convert a malformed JSON 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( + "Cannot fetch 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. 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, + @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 + { + return JsonUtils.getObjectWriter().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; + } + + 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); + } + + /** + * 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..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 @@ -32,6 +32,693 @@ 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",
+ *   "status": "200",
+ *   "response": {
+ *     "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ],
+ *     "id": "fa1afe1",
+ *     "userName": "walker"
+ *   }
+ * }
+ * 
+ * + * 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",
+ *   "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"
+ *   }
+ * }
+ * 
+ * + * 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", "status", + "response" }) 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. + */ + @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 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: + *
    + *
  • 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; + + /** + * 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 bulkVal = (method == BulkOpType.POST) ? operation.getBulkId() : null; + setBulkId(bulkVal); + } + + /** + * 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( + "Failed to convert a malformed JSON 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 + { + return JsonUtils.getObjectWriter().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; + } + + 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); + } + + /** + * 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..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 @@ -32,18 +32,489 @@ 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 network processing overhead, namely DNS + * 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. + *

+ * + * 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",
+ *     "status": "400",
+ *     "response": {
+ *       "schemas": [ "urn:ietf:params:scim:api:messages:2.0:Error" ],
+ *       "scimType": "uniqueness",
+ *       "detail": "The requested username was not available.",
+ *       "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!"
+ *       "id": "5b261bdf",
+ *       "meta": {
+ *         "created": "1970-01-01T13:00:00Z"
+ *       },
+ *     }
+ *   } ]
+ * }
+ * 
+ * + * 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 bulk payload 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(
+ *       getClientBulkOperation(),
+ *       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 more info + * below), and 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, 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;
+ *   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 applications 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, 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 + * 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 + * 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,15 +523,164 @@ 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; } /** - * {@inheritDoc} + * 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. 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, + @NotNull final String realValue) + { + for (BulkOperation op : operations) + { + op.replaceBulkIdValue(bulkId, realValue); + } + } + + /** + * Returns an iterator over operations contained in this bulk request. */ @Override @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; + } + + return Objects.equals(failOnErrors, that.failOnErrors) + && 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/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 f334c147..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 @@ -32,23 +32,197 @@ 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",
+ *         "status": "201",
+ *         "response": {
+ *           "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ],
+ *           "userName": "Alice"
+ *         }
+ *       }
+ *     ]
+ *   }
+ * 
+ * + * 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 HTTP response status code, and the optional JSON response data. + *

+ * + * 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(); + } + /** - * {@inheritDoc} + * 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; + } + + /** + * Returns an iterator over results contained in this bulk response. */ @Override @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..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 @@ -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,62 @@ else if (child instanceof ObjectNode childObject) } } + /** + * Recursively traverses a JsonNode and replaces all text values with the + * 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 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 oldValue, + @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(oldValue)) + { + // Replace the text node with the updated value + objectNode.put(field.getKey(), text.replace(oldValue, newValue)); + } + } + else if (valueNode.isObject() || valueNode.isArray()) + { + // Recursively resolve nested values. + replaceAllTextValues(valueNode, oldValue, 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(oldValue)) + { + array.set(i, TextNode.valueOf(text.replace(oldValue, newValue))); + } + } + else if (element.isObject() || element.isArray()) + { + // Recursively resolve nested values. + replaceAllTextValues(element, oldValue, 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..43d03cbb --- /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("Failed to convert a malformed JSON into") + .hasMessageContaining("a 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(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..f5f8cf0c --- /dev/null +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkOperationTest.java @@ -0,0 +1,457 @@ +/* + * 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); + assertThat(bulkPatch.getPatchOperationList() != patchList).isTrue(); + + // 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("Failed to convert a malformed JSON into") + .hasMessageContaining("a 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..b0de97a0 --- /dev/null +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkResponseTest.java @@ -0,0 +1,207 @@ +/* + * 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 com.unboundid.scim2.common.bulk.BulkOperationResult.HTTP_STATUS_OK; +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 result1 = new BulkOperationResult(BulkOpType.DELETE, + HTTP_STATUS_NO_CONTENT, + "https://example.com/v2/Users/fa1afe1", + null, + null, + "W/\"4weymrEsh5O6cAEK\""); + BulkOperationResult result2 = new BulkOperationResult(BulkOpType.PUT, + HTTP_STATUS_OK, + "https://example.com/v2/Users/5ca1ab1e", + null, + null, + null); + + // 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(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()); + } +} 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..03a03307 --- /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", + "status": "201", + "response": { + "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], + "userName": "silhouette.man" + } + }, { + "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" + } + }, { + "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", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" + ], + "externalId": "tarmac", + "userName": "tarmac" + } + } ] + }""").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..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 @@ -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,190 @@ 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(); + assertThat(boResult.isClientError()).isFalse(); + assertThat(boResult.isServerError()).isFalse(); + + 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(); + assertThat(boResult.isClientError()).isFalse(); + assertThat(boResult.isServerError()).isFalse(); + + 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(); + assertThat(boResult.isSuccess()).isFalse(); + assertThat(boResult.isServerError()).isFalse(); + + 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"); + assertThat(boResult.isSuccess()).isTrue(); + assertThat(boResult.isClientError()).isFalse(); + assertThat(boResult.isServerError()).isFalse(); + + ScimResource g = boResult.getResponseAsScimResource(); + assertThat(g).isInstanceOfSatisfying(GenericScimResource.class, + gsr -> assertThat(gsr.getExternalId()).isEqualTo("tarmac")); + }); + } + + private void setMeta(Class resourceClass, ScimResource scimResource) { ResourceTypeResource resourceType =