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 =