From 2ae88487d91a995a1c3a79d33b7228a58c5e7919 Mon Sep 17 00:00:00 2001 From: Khalid Qarryzada Date: Tue, 10 Mar 2026 14:00:33 -0700 Subject: [PATCH 1/4] Phase I: Bulk Operation Support This commit adds phase I of support for SCIM bulk request management for client and server applications. This initial phase focuses on the object models for bulk entities. This also includes a BulkResourceMapper, which will help provide an API for obtaining the "data" and "response" fields of bulk requests/responses as Java POJOs, simplifying object handling for client applications. This commit also includes updates for the exception package. New errors related to bulk requests (HTTP 413 and HTTP 429) have been added. Additionally, HTTP status values can now be obtained from exception objects. Reviewer: braveulysses Reviewer: dougbulkley Reviewer: selliott512 Reviewer: vyhhuang JiraIssue: DS-39451 --- CHANGELOG.md | 8 +- pom.xml | 2 +- scim2-assembly/pom.xml | 2 +- scim2-sdk-client/pom.xml | 2 +- .../unboundid/scim2/client/ScimService.java | 50 +++ .../client/requests/BulkRequestBuilder.java | 156 ++++++++++ .../scim2/client/RequestBuilderTest.java | 15 + scim2-sdk-common/pom.xml | 2 +- .../scim2/common/bulk/BulkOpType.java | 80 +++++ .../scim2/common/bulk/BulkOperation.java | 37 +++ .../common/bulk/BulkOperationResult.java | 37 +++ .../scim2/common/bulk/BulkRequest.java | 66 ++++ .../scim2/common/bulk/BulkResourceMapper.java | 289 ++++++++++++++++++ .../scim2/common/bulk/BulkResponse.java | 54 ++++ .../scim2/common/bulk/package-info.java | 39 +++ .../exceptions/BadRequestException.java | 29 +- .../exceptions/ContentTooLargeException.java | 136 +++++++++ .../common/exceptions/ForbiddenException.java | 29 +- .../exceptions/MethodNotAllowedException.java | 27 +- .../exceptions/NotImplementedException.java | 29 +- .../exceptions/NotModifiedException.java | 31 +- .../PreconditionFailedException.java | 31 +- .../common/exceptions/RateLimitException.java | 119 ++++++++ .../exceptions/ResourceConflictException.java | 31 +- .../exceptions/ResourceNotFoundException.java | 29 +- .../common/exceptions/ScimException.java | 6 +- .../exceptions/ServerErrorException.java | 30 +- .../exceptions/UnauthorizedException.java | 29 +- .../scim2/common/types/GroupResource.java | 5 +- .../types/ServiceProviderConfigResource.java | 4 +- .../scim2/common/utils/ApiConstants.java | 8 + .../common/utils/BulkStatusDeserializer.java | 101 ++++++ .../common/bulk/BulkResourceMapperTest.java | 131 ++++++++ .../common/exceptions/ScimExceptionTest.java | 98 ++++++ scim2-sdk-server/pom.xml | 2 +- scim2-ubid-extensions/pom.xml | 2 +- 36 files changed, 1711 insertions(+), 35 deletions(-) create mode 100644 scim2-sdk-client/src/main/java/com/unboundid/scim2/client/requests/BulkRequestBuilder.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOpType.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperation.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperationResult.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkRequest.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkResourceMapper.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkResponse.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/package-info.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/ContentTooLargeException.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/RateLimitException.java create mode 100644 scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/BulkStatusDeserializer.java create mode 100644 scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkResourceMapperTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 373d15ba..395c291d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,18 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). -## v5.0.1 - TBD +## v5.1.0 - TBD Updated Jackson to 2.20.1. Updated `ErrorResponse.java` to print attributes in an order that is more consistent with the example JSON objects presented in RFC 7644. Now, `status` is the last attribute printed. +Added new `ContentTooLargeException` and `RateLimitException` classes corresponding to the HTTP 413 +and HTTP 429 status codes. + +Added new `status()` and `statusInt()` method to all exception types. For example, to obtain a +string of `"400"` corresponding to `400 BAD REQUEST`, use `BadRequestException.status()`. + ## v5.0.0 - 2025-Dec-15 For consistency with other open source Ping Identity software, the UnboundID SCIM 2 SDK for Java is now available under the terms of the Apache License (version 2.0). For legacy compatibility, the diff --git a/pom.xml b/pom.xml index 8258e5ae..2025047f 100644 --- a/pom.xml +++ b/pom.xml @@ -33,7 +33,7 @@ 4.0.0 com.unboundid.product.scim2 scim2-parent - 5.0.1-SNAPSHOT + 5.1.0-SNAPSHOT pom UnboundID SCIM2 SDK Parent diff --git a/scim2-assembly/pom.xml b/scim2-assembly/pom.xml index 4c4c9547..4aeea7b5 100644 --- a/scim2-assembly/pom.xml +++ b/scim2-assembly/pom.xml @@ -34,7 +34,7 @@ com.unboundid.product.scim2 scim2-parent - 5.0.1-SNAPSHOT + 5.1.0-SNAPSHOT ../pom.xml scim2-assembly diff --git a/scim2-sdk-client/pom.xml b/scim2-sdk-client/pom.xml index be428e30..8822237d 100644 --- a/scim2-sdk-client/pom.xml +++ b/scim2-sdk-client/pom.xml @@ -34,7 +34,7 @@ com.unboundid.product.scim2 scim2-parent - 5.0.1-SNAPSHOT + 5.1.0-SNAPSHOT ../pom.xml scim2-sdk-client diff --git a/scim2-sdk-client/src/main/java/com/unboundid/scim2/client/ScimService.java b/scim2-sdk-client/src/main/java/com/unboundid/scim2/client/ScimService.java index de90f668..25e34e93 100644 --- a/scim2-sdk-client/src/main/java/com/unboundid/scim2/client/ScimService.java +++ b/scim2-sdk-client/src/main/java/com/unboundid/scim2/client/ScimService.java @@ -33,6 +33,7 @@ package com.unboundid.scim2.client; import com.fasterxml.jackson.jakarta.rs.json.JacksonJsonProvider; +import com.unboundid.scim2.client.requests.BulkRequestBuilder; import com.unboundid.scim2.client.requests.CreateRequestBuilder; import com.unboundid.scim2.client.requests.DeleteRequestBuilder; import com.unboundid.scim2.client.requests.ModifyRequestBuilder; @@ -575,6 +576,55 @@ public DeleteRequestBuilder deleteRequest( return deleteRequest(checkAndGetLocation(resource)); } + /** + * Build a request that will perform multiple write operations in a single API + * call. See {@link com.unboundid.scim2.common.bulk.BulkRequest BulkRequest} + * for more information. + * + * @return The request builder that may be used to specify additional + * operations and to invoke the request. + */ + @NotNull + public BulkRequestBuilder bulkRequest() + { + return new BulkRequestBuilder(baseTarget.path("Bulk")); + } + + /** + * Build a request that will perform multiple write operations in a single API + * call to the specified endpoint. In general, {@link #bulkRequest()} should + * be used to target the {@code /Bulk} endpoint defined in the specification. + * See {@link com.unboundid.scim2.common.bulk.BulkRequest BulkRequest} for + * more information. + * + * @param endpoint The resource endpoint, e.g., {@code /Bulk}. + * @return The request builder that may be used to specify additional + * operations and to invoke the request. + */ + @NotNull + public BulkRequestBuilder bulkRequest(@NotNull final String endpoint) + { + return new BulkRequestBuilder(baseTarget.path(endpoint)); + } + + /** + * Build a request that will perform multiple write operations in a single API + * call to the specified URI. In general, {@link #bulkRequest()} should be + * used to target the {@code /Bulk} endpoint defined in the specification. See + * {@link com.unboundid.scim2.common.bulk.BulkRequest BulkRequest} for more + * information. + * + * @param uri The URI referencing a SCIM service's bulk request endpoint, + * e.g., {@code https://example.com/v2/Bulk}. + * @return The request builder that may be used to specify additional + * operations and to invoke the request. + */ + @NotNull + public BulkRequestBuilder bulkRequest(@NotNull final URI uri) + { + return new BulkRequestBuilder(resolveWebTarget(uri)); + } + /** * Resolve a URL (relative or absolute) to a web target. * diff --git a/scim2-sdk-client/src/main/java/com/unboundid/scim2/client/requests/BulkRequestBuilder.java b/scim2-sdk-client/src/main/java/com/unboundid/scim2/client/requests/BulkRequestBuilder.java new file mode 100644 index 00000000..6ab25926 --- /dev/null +++ b/scim2-sdk-client/src/main/java/com/unboundid/scim2/client/requests/BulkRequestBuilder.java @@ -0,0 +1,156 @@ +/* + * 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.client.requests; + +import com.unboundid.scim2.common.annotations.NotNull; +import com.unboundid.scim2.common.annotations.Nullable; +import com.unboundid.scim2.common.exceptions.ScimException; +import com.unboundid.scim2.common.bulk.BulkOperation; +import com.unboundid.scim2.common.bulk.BulkRequest; +import com.unboundid.scim2.common.bulk.BulkResponse; +import jakarta.ws.rs.HttpMethod; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.client.WebTarget; +import jakarta.ws.rs.core.Response; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static jakarta.ws.rs.core.Response.Status.Family.SUCCESSFUL; + + +/** + * This class provides a builder for SCIM 2 bulk requests. Bulk requests provide + * a way for clients to send multiple write operations in a single API call. + *

+ * + * For more information, see the documentation in {@link BulkRequest}. + */ +public class BulkRequestBuilder + extends ResourceReturningRequestBuilder +{ + @NotNull + private final List operations = new ArrayList<>(); + + /** + * Create a new bulk request builder that will be used to apply a list of + * write operations to the given web target. + * + * @param target The WebTarget that will receive the bulk request. + */ + public BulkRequestBuilder(@NotNull final WebTarget target) + { + super(target); + } + + /** + * Append a list of bulk operations to this bulk request. Any {@code null} + * values will be ignored. + * + * @param ops The list of bulk operations to add. + * + * @return This bulk request builder. + */ + @NotNull + public BulkRequestBuilder append(@Nullable final List ops) + { + List operationList = (ops == null) ? List.of() : ops; + + // Null fields are handled when the bulk request is constructed. + operations.addAll(operationList); + return this; + } + + /** + * Append one or more bulk operations to this bulk request. Any {@code null} + * values will be ignored. + * + * @param operations The bulk operation(s) to add. + * + * @return This bulk request builder. + */ + @NotNull + public BulkRequestBuilder append(@Nullable final BulkOperation... operations) + { + if (operations != null) + { + append(Arrays.asList(operations)); + } + + return this; + } + + /** + * Invoke the SCIM bulk request and return the response. + * + * @return The bulk response representing the summary of the write requests + * that were attempted and whether they were successful. + * @throws ScimException If the SCIM service responded with an error. + */ + @NotNull + public BulkResponse invoke() throws ScimException + { + return invoke(BulkResponse.class); + } + + /** + * Invoke the SCIM bulk request. + * + * @param The Java type to return. This should be a {@link BulkResponse}. + * @param cls The Java type to return. This should be a {@link BulkResponse}. + * + * @return The bulk response. + * @throws ScimException If the SCIM service responded with an error, such + * as if the JSON body was too large. + */ + @NotNull + protected C invoke(@NotNull final Class cls) + throws ScimException + { + BulkRequest request = new BulkRequest(operations); + var entity = Entity.entity(generify(request), getContentType()); + + try (Response response = buildRequest().method(HttpMethod.POST, entity)) + { + if (response.getStatusInfo().getFamily() == SUCCESSFUL) + { + return response.readEntity(cls); + } + else + { + throw toScimException(response); + } + } + } +} diff --git a/scim2-sdk-client/src/test/java/com/unboundid/scim2/client/RequestBuilderTest.java b/scim2-sdk-client/src/test/java/com/unboundid/scim2/client/RequestBuilderTest.java index 09e1b4ad..5bcf907b 100644 --- a/scim2-sdk-client/src/test/java/com/unboundid/scim2/client/RequestBuilderTest.java +++ b/scim2-sdk-client/src/test/java/com/unboundid/scim2/client/RequestBuilderTest.java @@ -32,6 +32,7 @@ package com.unboundid.scim2.client; +import com.unboundid.scim2.client.requests.BulkRequestBuilder; import com.unboundid.scim2.client.requests.CreateRequestBuilder; import com.unboundid.scim2.client.requests.DeleteRequestBuilder; import com.unboundid.scim2.client.requests.ReplaceRequestBuilder; @@ -112,5 +113,19 @@ public CustomSearch(WebTarget target) var searchInstance = new CustomSearch(target); searchInstance.otherField = "present"; assertThat(searchInstance.otherField).isEqualTo("present"); + + // Test BulkRequestBuilder. + class CustomBulk extends BulkRequestBuilder + { + public String otherField; + + public CustomBulk(WebTarget target) + { + super(target); + } + } + var bulkInstance = new CustomBulk(target); + bulkInstance.otherField = "present"; + assertThat(bulkInstance.otherField).isEqualTo("present"); } } diff --git a/scim2-sdk-common/pom.xml b/scim2-sdk-common/pom.xml index ca905fb4..4b59a53d 100644 --- a/scim2-sdk-common/pom.xml +++ b/scim2-sdk-common/pom.xml @@ -34,7 +34,7 @@ com.unboundid.product.scim2 scim2-parent - 5.0.1-SNAPSHOT + 5.1.0-SNAPSHOT ../pom.xml scim2-sdk-common diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOpType.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOpType.java new file mode 100644 index 00000000..bc67996d --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOpType.java @@ -0,0 +1,80 @@ +/* + * 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.annotations.NotNull; + + +/** + * An enum representing possible bulk operation types. + */ +public enum BulkOpType +{ + /** + * The "POST" bulk operation type used to create a resource. + */ + POST("POST"), + + /** + * The "PUT" bulk operation type used to overwrite a resource. + */ + PUT("PUT"), + + /** + * The "PATCH" bulk operation type used to update part of a resource. + */ + PATCH("PATCH"), + + /** + * The "DELETE" bulk operation type used to delete a resource. + */ + DELETE("DELETE"), + + // GET is not available, since bulk operations are for writes. + ; + + @NotNull + private final String stringValue; + + BulkOpType(@NotNull final String stringValue) + { + this.stringValue = stringValue; + } + + @Override + @NotNull + public String toString() + { + return stringValue; + } +} diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperation.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperation.java new file mode 100644 index 00000000..ab5e7b05 --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperation.java @@ -0,0 +1,37 @@ +/* + * 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; + +public abstract class BulkOperation +{ +} 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 new file mode 100644 index 00000000..0cf72f45 --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkOperationResult.java @@ -0,0 +1,37 @@ +/* + * 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; + +public class BulkOperationResult +{ +} 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 new file mode 100644 index 00000000..8a3265da --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkRequest.java @@ -0,0 +1,66 @@ +/* + * 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.BaseScimResource; +import com.unboundid.scim2.common.annotations.NotNull; +import com.unboundid.scim2.common.annotations.Nullable; + +import java.util.Collections; +import java.util.Iterator; +import java.util.List; + + +public class BulkRequest extends BaseScimResource + implements Iterable +{ + /** + * Creates a bulk request. + * + * @param operations The list of {@link BulkOperation} objects. Any + * {@code null} elements will be ignored. + */ + public BulkRequest(@Nullable final List operations) + { + } + + /** + * {@inheritDoc} + */ + @Override + @NotNull + public Iterator iterator() + { + return Collections.emptyIterator(); + } +} 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 new file mode 100644 index 00000000..84846a66 --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkResourceMapper.java @@ -0,0 +1,289 @@ +/* + * 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.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.unboundid.scim2.common.GenericScimResource; +import com.unboundid.scim2.common.ScimResource; +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.messages.ErrorResponse; +import com.unboundid.scim2.common.types.GroupResource; +import com.unboundid.scim2.common.types.SchemaResource; +import com.unboundid.scim2.common.types.UserResource; +import com.unboundid.scim2.common.utils.Debug; +import com.unboundid.scim2.common.utils.JsonUtils; +import com.unboundid.scim2.common.utils.SchemaUtils; + +import java.beans.IntrospectionException; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; + + +/** + * This class is used to simplify the process of obtaining Java objects from + * bulk requests and responses. + *

+ * + * When dealing with bulk requests or responses, there is often embedded JSON + * data corresponding to a SCIM resource. For example, the value of the + * {@code response} field below represents a user that was just created: + *
+ *  {
+ *    "location": "/Users/fa1afe1",
+ *    "method": "POST",
+ *    "response": {
+ *      "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ],
+ *      "id": "fa1afe1",
+ *      "userName": "Polaroid"
+ *    },
+ *    "status": "200"
+ *  }
+ * 
+ *

+ * + * When it comes to handling and interpreting this data, it can be tedious to + * obtain the resource and convert it from JSON to a usable POJO. The JSON data + * may correspond to: + *
    + *
  • A {@link UserResource}, representing a user. + *
  • A {@link GroupResource}, representing a group entity. + *
  • A {@link ErrorResponse}, representing an error that occurred. + *
  • Any other custom resource type defined by a SCIM service provider. + *
+ *

+ * + * The definition of any SCIM resource type is available in the {@code schemas} + * attribute. However, fetching this data and conditionally converting it into + * different objects (with {@link JsonUtils#nodeToValue}) is extra work that the + * SCIM SDK aims to simplify. + *

+ * + * To solve this problem, this BulkResourceMapper class defines associations + * between schemas (represented as sets) and a Java type. For example, any JSON + * with a schemas value of {@code urn:ietf:params:scim:schemas:core:2.0:User} + * is interpreted as a {@link UserResource}. If you have custom resource types + * that need to be supported by the SCIM SDK's bulk processing, then add it with + * one of the following methods. + *
    + *
  • {@link #add(Class)}: If the class uses the {@link Schema} annotation. + *
  • {@link #put put()}: To define a direct relationship for any class. + *
+ *

+ * + * Note that any custom class must implement the {@link ScimResource} interface + * in order to be compatible with the methods above. Furthermore, modifying the + * BulkResourceMapper with the above methods must only be done at application + * startup. + */ +public class BulkResourceMapper +{ + /** + * The map that stores schema-to-class associations for this mapper class. + */ + // This does not need to be a concurrent map since it should only be + // configured/modified at application startup. + @NotNull + static final HashMap, Class> SCHEMAS_MAP = new HashMap<>(); + + static + { + initialize(); + } + + /** + * Updates the BulkResourceMapper with a new class. The provided class must + * include the {@link Schema} annotation. To add a different + * ScimResource-based class that does not use the annotation, use the + * {@link #put put()} method instead. + * + * @param clazz The class to register. + * @param The Java type, which must implement ScimResource. + * @throws IllegalArgumentException If the provided class does not have the + * required annotation. + */ + public static void add(@NotNull final Class clazz) + throws IllegalArgumentException + { + SchemaResource schema; + + try + { + schema = SchemaUtils.getSchema(clazz); + } + catch (IntrospectionException e) + { + Debug.debugException(e); + throw new RuntimeException(e); + } + + if (schema == null || schema.getId() == null) + { + throw new IllegalArgumentException("Requested schema for the " + + clazz.getName() + + " class, which does not have a valid @Schema annotation."); + } + + SCHEMAS_MAP.put(Set.of(schema.getId()), clazz); + } + + /** + * Updates the BulkResourceMapper with a new class. This is an alternative to + * the {@link #add(Class)} method that supports usage of Java classes that + * do not or cannot use the {@code @Schema} annotation. + * + * @param schemas The schemas associated with the resource type. + * @param clazz The class type that is associated with the resource type. + * @param The returned Java type. + */ + public static void put( + @NotNull final Set schemas, + @NotNull final Class clazz) + { + Objects.requireNonNull(schemas); + SCHEMAS_MAP.put(schemas, clazz); + } + + /** + * Clears all registered relationships between schemas and class types. + *

+ * + * This should generally not be called unless it is absolutely necessary to + * overwrite data in the map. + */ + public static void clear() + { + SCHEMAS_MAP.clear(); + } + + /** + * This utility method is the primary entrypoint to this class, and is + * responsible for converting JSON data into a ScimResource POJO. The subclass + * of ScimResource must be defined in the map contained within this class, or + * a {@link GenericScimResource} will be returned instead. + * + * @param json The JSON to convert. + * @return A {@link ScimResource} subclass, or {@code null} if the JSON + * was also {@code null}. + * + * @throws IllegalArgumentException If the JSON was malformed. + */ + @Nullable + public static ScimResource asScimResource(@Nullable final ObjectNode json) + throws IllegalArgumentException + { + if (json == null) + { + return null; + } + + // Attempt fetching the class using data from the "schemas" array. If there + // is not a mapping, a GenericScimResource will be used. + Class clazz = BulkResourceMapper.get(json.get("schemas")); + + try + { + return JsonUtils.nodeToValue(json, clazz); + } + catch (JsonProcessingException e) + { + throw new IllegalArgumentException( + "Failed to convert bulk data into a " + clazz.getName(), e); + } + } + + /** + * Fetches the class associated with the provided schema(s). If the schemas + * are not registered, a {@link GenericScimResource} is returned. + * + * @param s The {@code schemas} value of a SCIM resource. + * @param The returned Java type. + * + * @return The class type that is associated with the schemas, or a + * {@link GenericScimResource} if one is not defined. + */ + @NotNull + @SuppressWarnings("unchecked") + static Class get(@NotNull final Set s) + { + return (Class) SCHEMAS_MAP.getOrDefault(s, GenericScimResource.class); + } + + /** + * Fetches the class associated with the provided schema set. This is an + * alternative to {@link #get(Set)} which accepts a JsonNode. + * + * @param node The JsonNode representing the value of the {@code schemas} + * field on a SCIM resource. + * @param The returned Java type. + * + * @return The class type that is associated with the schemas, or a + * {@link GenericScimResource} if one is not defined. + */ + @NotNull + @SuppressWarnings("unchecked") + static Class get(@Nullable final JsonNode node) + { + if (!(node instanceof ArrayNode arrayNode)) + { + // 'schemas' should always be an array as defined by the SCIM standard. + return (Class) GenericScimResource.class; + } + + Set schemaSet = new HashSet<>(); + for (var value : arrayNode) + { + schemaSet.add(value.asText()); + } + + return get(schemaSet); + } + + /** + * Initializes this class by registering basic resource types that are + * generally applicable to SCIM clients. + */ + static void initialize() + { + clear(); + add(UserResource.class); + add(GroupResource.class); + add(ErrorResponse.class); + } +} 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 new file mode 100644 index 00000000..f334c147 --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkResponse.java @@ -0,0 +1,54 @@ +/* + * 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.BaseScimResource; +import com.unboundid.scim2.common.annotations.NotNull; + +import java.util.Collections; +import java.util.Iterator; + + +public class BulkResponse extends BaseScimResource + implements Iterable +{ + /** + * {@inheritDoc} + */ + @Override + @NotNull + public Iterator iterator() + { + return Collections.emptyIterator(); + } +} diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/package-info.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/package-info.java new file mode 100644 index 00000000..25c539af --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/package-info.java @@ -0,0 +1,39 @@ +/* + * 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 . + */ + +/** + * Classes representing SCIM 2 bulk requests, responses, operations, and more. + * For background on SCIM bulk processing and how the SDK supports it, see + * {@link com.unboundid.scim2.common.bulk.BulkRequest BulkRequest}. + */ + +package com.unboundid.scim2.common.bulk; diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/BadRequestException.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/BadRequestException.java index 3974e156..098c9162 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/BadRequestException.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/BadRequestException.java @@ -84,6 +84,8 @@ */ public class BadRequestException extends ScimException { + private static final int BAD_REQUEST_HTTP_STATUS = 400; + /** * The SCIM detailed error keyword that indicates the specified filter syntax * was invalid. @@ -177,6 +179,29 @@ public class BadRequestException extends ScimException public static final String INVALID_COUNT = "invalidCount"; + /** + * Returns the {@code 400 BAD REQUEST} HTTP status code value. + * + * @return The HTTP status value. + * @since 5.1.0 + */ + public static int statusInt() + { + return BAD_REQUEST_HTTP_STATUS; + } + + /** + * Returns the {@code 400 BAD REQUEST} HTTP status code string value. + * + * @return The HTTP status value as a string. + * @since 5.1.0 + */ + @NotNull + public static String status() + { + return "400"; + } + /** * Create a generic BadRequestException without a {@code scimType} field. * @@ -196,7 +221,7 @@ public BadRequestException(@Nullable final String errorMessage) public BadRequestException(@Nullable final String errorMessage, @Nullable final String scimType) { - super(400, scimType, errorMessage); + super(BAD_REQUEST_HTTP_STATUS, scimType, errorMessage); } /** @@ -228,7 +253,7 @@ public BadRequestException(@Nullable final String errorMessage, @Nullable final String scimType, @Nullable final Throwable cause) { - super(400, scimType, errorMessage, cause); + super(BAD_REQUEST_HTTP_STATUS, scimType, errorMessage, cause); } /** diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/ContentTooLargeException.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/ContentTooLargeException.java new file mode 100644 index 00000000..801667fd --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/ContentTooLargeException.java @@ -0,0 +1,136 @@ +/* + * 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.messages.ErrorResponse; + + +/** + * This class represents a SCIM exception pertaining to the + * {@code HTTP 413 CONTENT TOO LARGE} error response code. This exception type + * should be thrown when a client sends a request with a JSON body that exceeds + * the threshold for an acceptable payload size. This is most likely to occur + * when processing bulk operations that contain many different requests, but can + * also occur for other general endpoints. + *

+ * + * The following is an example of a ContentTooLargeException presented to a SCIM + * client: + *
+ *   {
+ *     "schemas": [ "urn:ietf:params:scim:api:messages:2.0:Error" ],
+ *     "detail": "The size of the request exceeds the maxPayloadSize (1048576)",
+ *     "status": "413"
+ *   }
+ * 
+ * + * To create the above error as an exception, use the following Java code: + *

+ *   throw new ContentTooLargeException(
+ *       "The size of the request exceeds the maxPayloadSize (" + MAX_SIZE + ")"
+ *   );
+ * 
+ * + * This exception type does not have a {@code scimType} value. + * + * @since 5.1.0 + */ +public class ContentTooLargeException extends ScimException +{ + private static final int CONTENT_TOO_LARGE_HTTP_STATUS = 413; + + /** + * Returns the {@code 413 CONTENT TOO LARGE} HTTP status code value. + * + * @return The HTTP status value. + */ + public static int statusInt() + { + return CONTENT_TOO_LARGE_HTTP_STATUS; + } + + /** + * Returns the {@code 413 CONTENT TOO LARGE} HTTP status code string value. + * + * @return The HTTP status value as a string. + */ + @NotNull + public static String status() + { + return "413"; + } + + /** + * Create a new {@code ContentTooLargeException} with the provided message. + * + * @param errorMessage The error message for this SCIM exception. + */ + public ContentTooLargeException(@Nullable final String errorMessage) + { + super(CONTENT_TOO_LARGE_HTTP_STATUS, errorMessage); + } + + /** + * Create a new {@code ContentTooLargeException} from the provided + * information. + * + * @param errorMessage The error message for this SCIM 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 ContentTooLargeException(@Nullable final String errorMessage, + @Nullable final Throwable cause) + { + super(CONTENT_TOO_LARGE_HTTP_STATUS, null, errorMessage, cause); + } + + /** + * Create a new {@code ContentTooLargeException} from the provided + * information. + * + * @param scimError The SCIM error response. + * @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 ContentTooLargeException(@NotNull final ErrorResponse scimError, + @Nullable final Throwable cause) + { + super(scimError, cause); + } +} diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/ForbiddenException.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/ForbiddenException.java index 5e0ad103..643898b7 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/ForbiddenException.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/ForbiddenException.java @@ -66,6 +66,8 @@ */ public class ForbiddenException extends ScimException { + private static final int FORBIDDEN_HTTP_STATUS = 403; + /** * The SCIM detailed error keyword that indicates the provided filter in a * GET search request contained sensitive or confidential information. See @@ -74,6 +76,29 @@ public class ForbiddenException extends ScimException @NotNull public static final String SENSITIVE = "sensitive"; + /** + * Returns the {@code 403 FORBIDDEN} HTTP status code value. + * + * @return The HTTP status value. + * @since 5.1.0 + */ + public static int statusInt() + { + return FORBIDDEN_HTTP_STATUS; + } + + /** + * Returns the {@code 403 FORBIDDEN} HTTP status code string value. + * + * @return The HTTP status value as a string. + * @since 5.1.0 + */ + @NotNull + public static String status() + { + return "403"; + } + /** * Create a new {@code ForbiddenException} from the provided information. * @@ -81,7 +106,7 @@ public class ForbiddenException extends ScimException */ public ForbiddenException(@Nullable final String errorMessage) { - super(403, null, errorMessage); + super(FORBIDDEN_HTTP_STATUS, null, errorMessage); } /** @@ -99,7 +124,7 @@ public ForbiddenException(@Nullable final String errorMessage, @Nullable final String scimType, @Nullable final Throwable cause) { - super(403, scimType, errorMessage, cause); + super(FORBIDDEN_HTTP_STATUS, scimType, errorMessage, cause); } /** diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/MethodNotAllowedException.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/MethodNotAllowedException.java index 54f34683..e9b922ba 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/MethodNotAllowedException.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/MethodNotAllowedException.java @@ -64,9 +64,32 @@ */ public class MethodNotAllowedException extends ScimException { - private static final int METHOD_NOT_ALLOWED_CODE = 405; + private static final int METHOD_NOT_ALLOWED_HTTP_STATUS = 405; + /** + * Returns the {@code 405 METHOD NOT ALLOWED} HTTP status code value. + * + * @return The HTTP status value. + * @since 5.1.0 + */ + public static int statusInt() + { + return METHOD_NOT_ALLOWED_HTTP_STATUS; + } + + /** + * Returns the {@code 405 METHOD NOT ALLOWED} HTTP status code string value. + * + * @return The HTTP status value as a string. + * @since 5.1.0 + */ + @NotNull + public static String status() + { + return "405"; + } + /** * Create a new {@code MethodNotAllowedException} from the provided * information. @@ -75,7 +98,7 @@ public class MethodNotAllowedException extends ScimException */ public MethodNotAllowedException(@Nullable final String errorMessage) { - super(METHOD_NOT_ALLOWED_CODE, errorMessage); + super(METHOD_NOT_ALLOWED_HTTP_STATUS, errorMessage); } diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/NotImplementedException.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/NotImplementedException.java index 40b0269e..e3363ed8 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/NotImplementedException.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/NotImplementedException.java @@ -67,6 +67,31 @@ */ public class NotImplementedException extends ScimException { + private static final int NOT_IMPLEMENTED_HTTP_STATUS = 501; + + /** + * Returns the {@code 501 NOT IMPLEMENTED} HTTP status code value. + * + * @return The HTTP status value. + * @since 5.1.0 + */ + public static int statusInt() + { + return NOT_IMPLEMENTED_HTTP_STATUS; + } + + /** + * Returns the {@code 501 NOT IMPLEMENTED} HTTP status code string value. + * + * @return The HTTP status value as a string. + * @since 5.1.0 + */ + @NotNull + public static String status() + { + return "501"; + } + /** * Create a new {@code NotImplementedException} from the provided information. * @@ -74,7 +99,7 @@ public class NotImplementedException extends ScimException */ public NotImplementedException(@Nullable final String errorMessage) { - super(501, null, errorMessage); + super(NOT_IMPLEMENTED_HTTP_STATUS, null, errorMessage); } /** @@ -92,7 +117,7 @@ public NotImplementedException(@Nullable final String errorMessage, @Nullable final String scimType, @Nullable final Throwable cause) { - super(501, scimType, errorMessage, cause); + super(NOT_IMPLEMENTED_HTTP_STATUS, scimType, errorMessage, cause); } /** diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/NotModifiedException.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/NotModifiedException.java index c02c6029..6afb5e05 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/NotModifiedException.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/NotModifiedException.java @@ -64,12 +64,37 @@ */ public class NotModifiedException extends ScimException { + private static final int NOT_MODIFIED_HTTP_STATUS = 304; + /** * Represents the ETag version value of the resource. */ @Nullable private final String version; + /** + * Returns the {@code 304 NOT MODIFIED} HTTP status code value. + * + * @return The HTTP status value. + * @since 5.1.0 + */ + public static int statusInt() + { + return NOT_MODIFIED_HTTP_STATUS; + } + + /** + * Returns the {@code 304 NOT MODIFIED} HTTP status code string value. + * + * @return The HTTP status value as a string. + * @since 5.1.0 + */ + @NotNull + public static String status() + { + return "304"; + } + /** * Create a new {@code NotModifiedException} from the provided information. * @@ -77,7 +102,7 @@ public class NotModifiedException extends ScimException */ public NotModifiedException(@Nullable final String errorMessage) { - super(304, null, errorMessage); + super(NOT_MODIFIED_HTTP_STATUS, null, errorMessage); version = null; } @@ -93,7 +118,7 @@ public NotModifiedException(@Nullable final String errorMessage) public NotModifiedException(@Nullable final String errorMessage, @Nullable final Throwable cause) { - super(304, null, errorMessage, cause); + super(NOT_MODIFIED_HTTP_STATUS, null, errorMessage, cause); version = null; } @@ -113,7 +138,7 @@ public NotModifiedException(@Nullable final String errorMessage, @Nullable final String version, @Nullable final Throwable cause) { - super(304, scimType, errorMessage, cause); + super(NOT_MODIFIED_HTTP_STATUS, scimType, errorMessage, cause); this.version = version; } diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/PreconditionFailedException.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/PreconditionFailedException.java index 1809d673..e441f5b2 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/PreconditionFailedException.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/PreconditionFailedException.java @@ -67,12 +67,37 @@ */ public class PreconditionFailedException extends ScimException { + private static final int PRECONDITION_FAILED_HTTP_STATUS = 412; + /** * Represents the ETag version value of the resource. */ @Nullable private final String version; + /** + * Returns the {@code 412 PRECONDITION FAILED} HTTP status code value. + * + * @return The HTTP status value. + * @since 5.1.0 + */ + public static int statusInt() + { + return PRECONDITION_FAILED_HTTP_STATUS; + } + + /** + * Returns the {@code 412 PRECONDITION FAILED} HTTP status code string value. + * + * @return The HTTP status value as a string. + * @since 5.1.0 + */ + @NotNull + public static String status() + { + return "412"; + } + /** * Create a new {@code PreconditionFailedException} from the provided * information. @@ -81,7 +106,7 @@ public class PreconditionFailedException extends ScimException */ public PreconditionFailedException(@Nullable final String errorMessage) { - super(412, null, errorMessage); + super(PRECONDITION_FAILED_HTTP_STATUS, null, errorMessage); this.version = null; } @@ -98,7 +123,7 @@ public PreconditionFailedException(@Nullable final String errorMessage) public PreconditionFailedException(@Nullable final String errorMessage, @Nullable final Throwable cause) { - super(412, null, errorMessage, cause); + super(PRECONDITION_FAILED_HTTP_STATUS, null, errorMessage, cause); this.version = null; } @@ -119,7 +144,7 @@ public PreconditionFailedException(@Nullable final String errorMessage, @Nullable final String version, @Nullable final Throwable cause) { - super(412, scimType, errorMessage, cause); + super(PRECONDITION_FAILED_HTTP_STATUS, scimType, errorMessage, cause); this.version = version; } diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/RateLimitException.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/RateLimitException.java new file mode 100644 index 00000000..aaddef5b --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/RateLimitException.java @@ -0,0 +1,119 @@ +/* + * 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.messages.ErrorResponse; + + +/** + * This class represents a SCIM exception pertaining to the + * {@code HTTP 429 TOO MANY REQUESTS} error response code. This exception type + * is thrown when a client exceeds a defined rate limit of an HTTP service, and + * occurs commonly when a client sends excessive requests within a brief period + * of time. By denying excessive request volumes, the service defends against + * expensive traffic that could affect availability. This also defends against + * malicious actors that attempt to overwhelm the service with attacks such as a + * distributed denial of service. + *

+ * + * The following is an example of a rate limit exceeded exception presented to + * a SCIM client: + *
+ * {
+ *   "schemas": [ "urn:ietf:params:scim:api:messages:2.0:Error" ],
+ *   "detail": "Too many requests. Please try again later.",
+ *   "status": "429"
+ * }
+ * 
+ * + * This exception can be created with the following Java code: + *

+ *   throw new RateLimitException("Too many requests. Please try again later.");
+ * 
+ * + * This exception type does not have a {@code scimType} value. + * + * @since 5.1.0 + */ +public class RateLimitException extends ScimException +{ + private static final int TOO_MANY_REQUESTS_HTTP_STATUS = 429; + + /** + * Returns the {@code 429 TOO MANY REQUESTS} HTTP status code value. + * + * @return The HTTP status value. + */ + public static int statusInt() + { + return TOO_MANY_REQUESTS_HTTP_STATUS; + } + + /** + * Returns the {@code 429 TOO MANY REQUESTS} HTTP status code string value. + * + * @return The HTTP status value as a string. + */ + @NotNull + public static String status() + { + return "429"; + } + + /** + * Create a new {@code RateLimitException} from the provided information. + * + * @param errorMessage The error message for this SCIM exception. + */ + public RateLimitException(@Nullable final String errorMessage) + { + super(TOO_MANY_REQUESTS_HTTP_STATUS, errorMessage); + } + + /** + * Create a new {@code RateLimitException} from the provided information. + * + * @param scimError The SCIM error response. + * @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 RateLimitException(@NotNull final ErrorResponse scimError, + @Nullable final Throwable cause) + { + super(scimError, cause); + } +} diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/ResourceConflictException.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/ResourceConflictException.java index 81b8c336..5f6f9099 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/ResourceConflictException.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/ResourceConflictException.java @@ -81,12 +81,37 @@ */ public class ResourceConflictException extends ScimException { + private static final int CONFLICT_HTTP_STATUS = 409; + /** * The SCIM detailed error keyword that indicates a uniqueness conflict. */ @NotNull public static final String UNIQUENESS = "uniqueness"; + /** + * Returns the {@code 409 CONFLICT} HTTP status code value. + * + * @return The HTTP status value. + * @since 5.1.0 + */ + public static int statusInt() + { + return CONFLICT_HTTP_STATUS; + } + + /** + * Returns the {@code 409 CONFLICT} HTTP status code string value. + * + * @return The HTTP status value as a string. + * @since 5.1.0 + */ + @NotNull + public static String status() + { + return "409"; + } + /** * Create a new {@code ResourceConflictException} from the provided * information. This constructor sets the {@code scimType} field to @@ -96,7 +121,7 @@ public class ResourceConflictException extends ScimException */ public ResourceConflictException(@Nullable final String errorMessage) { - super(409, null, errorMessage); + super(CONFLICT_HTTP_STATUS, null, errorMessage); } /** @@ -110,7 +135,7 @@ public ResourceConflictException(@Nullable final String errorMessage) public ResourceConflictException(@Nullable final String errorMessage, @Nullable final String scimType) { - super(409, scimType, errorMessage); + super(CONFLICT_HTTP_STATUS, scimType, errorMessage); } /** @@ -143,7 +168,7 @@ public ResourceConflictException(@Nullable final String errorMessage, @Nullable final String scimType, @Nullable final Throwable cause) { - super(409, scimType, errorMessage, cause); + super(CONFLICT_HTTP_STATUS, scimType, errorMessage, cause); } /** diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/ResourceNotFoundException.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/ResourceNotFoundException.java index c2d367bf..dcb4dd48 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/ResourceNotFoundException.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/ResourceNotFoundException.java @@ -65,6 +65,31 @@ */ public class ResourceNotFoundException extends ScimException { + private static final int NOT_FOUND_HTTP_STATUS = 404; + + /** + * Returns the {@code 404 NOT FOUND} HTTP status code value. + * + * @return The HTTP status value. + * @since 5.1.0 + */ + public static int statusInt() + { + return NOT_FOUND_HTTP_STATUS; + } + + /** + * Returns the {@code 404 NOT FOUND} HTTP status code string value. + * + * @return The HTTP status value as a string. + * @since 5.1.0 + */ + @NotNull + public static String status() + { + return "404"; + } + /** * Create a new {@code ResourceNotFoundException} from the provided * information. @@ -73,7 +98,7 @@ public class ResourceNotFoundException extends ScimException */ public ResourceNotFoundException(@Nullable final String errorMessage) { - super(404, null, errorMessage); + super(NOT_FOUND_HTTP_STATUS, null, errorMessage); } /** @@ -91,7 +116,7 @@ public ResourceNotFoundException(@Nullable final String errorMessage, @Nullable final String scimType, @Nullable final Throwable cause) { - super(404, scimType, errorMessage, cause); + super(NOT_FOUND_HTTP_STATUS, scimType, errorMessage, cause); } /** diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/ScimException.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/ScimException.java index 8f39da33..ed7ba40a 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/ScimException.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/ScimException.java @@ -67,6 +67,8 @@ *
  • {@code HTTP 405}: {@link MethodNotAllowedException} *
  • {@code HTTP 409}: {@link ResourceConflictException} *
  • {@code HTTP 412}: {@link PreconditionFailedException} + *
  • {@code HTTP 413}: {@link ContentTooLargeException} + *
  • {@code HTTP 429}: {@link RateLimitException} *
  • {@code HTTP 500}: {@link ServerErrorException} *
  • {@code HTTP 501}: {@link NotImplementedException} * @@ -90,7 +92,7 @@ * a dedicated class defined in the SCIM SDK, the constructors on this class may * be used directly: *
    
    - *   throw new ScimException(429, "Detailed error message");
    + *   throw new ScimException(418, "Detailed error message");
      * 
    * * For more details on a particular exception type, see the class-level @@ -263,6 +265,8 @@ public static ScimException createException( case 405 -> new MethodNotAllowedException(scimError, cause); case 409 -> new ResourceConflictException(scimError, cause); case 412 -> new PreconditionFailedException(scimError, null, cause); + case 413 -> new ContentTooLargeException(scimError, cause); + case 429 -> new RateLimitException(scimError, cause); case 500 -> new ServerErrorException(scimError, cause); case 501 -> new NotImplementedException(scimError, cause); default -> new ScimException(scimError, cause); diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/ServerErrorException.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/ServerErrorException.java index 8fe40ae5..b97cc415 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/ServerErrorException.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/ServerErrorException.java @@ -62,6 +62,32 @@ */ public class ServerErrorException extends ScimException { + private static final int INTERNAL_SERVER_ERROR_HTTP_STATUS = 500; + + /** + * Returns the {@code 500 INTERNAL SERVER ERROR} HTTP status code value. + * + * @return The HTTP status value. + * @since 5.1.0 + */ + public static int statusInt() + { + return INTERNAL_SERVER_ERROR_HTTP_STATUS; + } + + /** + * Returns the {@code 500 INTERNAL SERVER ERROR} HTTP status code string + * value. + * + * @return The HTTP status value as a string. + * @since 5.1.0 + */ + @NotNull + public static String status() + { + return "500"; + } + /** * Create a new {@code ServerErrorException} from the provided information. * @@ -69,7 +95,7 @@ public class ServerErrorException extends ScimException */ public ServerErrorException(@Nullable final String errorMessage) { - super(500, null, errorMessage); + super(INTERNAL_SERVER_ERROR_HTTP_STATUS, null, errorMessage); } /** @@ -86,7 +112,7 @@ public ServerErrorException(@Nullable final String errorMessage, @Nullable final String scimType, @Nullable final Throwable cause) { - super(500, scimType, errorMessage, cause); + super(INTERNAL_SERVER_ERROR_HTTP_STATUS, scimType, errorMessage, cause); } /** diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/UnauthorizedException.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/UnauthorizedException.java index 772b8fc3..8deccb8d 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/UnauthorizedException.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/exceptions/UnauthorizedException.java @@ -66,6 +66,31 @@ */ public class UnauthorizedException extends ScimException { + private static final int UNAUTHORIZED_HTTP_STATUS = 401; + + /** + * Returns the {@code 401 UNAUTHORIZED} HTTP status code value. + * + * @return The HTTP status value. + * @since 5.1.0 + */ + public static int statusInt() + { + return UNAUTHORIZED_HTTP_STATUS; + } + + /** + * Returns the {@code 401 UNAUTHORIZED} HTTP status code string value. + * + * @return The HTTP status value as a string. + * @since 5.1.0 + */ + @NotNull + public static String status() + { + return "401"; + } + /** * Create a new {@code UnauthorizedException} from the provided information. * @@ -73,7 +98,7 @@ public class UnauthorizedException extends ScimException */ public UnauthorizedException(@Nullable final String errorMessage) { - super(401, null, errorMessage); + super(UNAUTHORIZED_HTTP_STATUS, null, errorMessage); } /** @@ -90,7 +115,7 @@ public UnauthorizedException(@Nullable final String errorMessage, @Nullable final String scimType, @Nullable final Throwable cause) { - super(401, scimType, errorMessage, cause); + super(UNAUTHORIZED_HTTP_STATUS, scimType, errorMessage, cause); } /** diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/GroupResource.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/GroupResource.java index cb4f85d4..cefb2fae 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/GroupResource.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/GroupResource.java @@ -163,8 +163,9 @@ public GroupResource setMembers(@Nullable final List members) * Alternate version of {@link #setMembers(List)} that accepts individual * Member objects that are not contained in a list. * - * @param member The first member to add. This must not be {@code null}. - * @param members An optional set of additional arguments. Any {@code null} + * @param member The first member of the group. This must not be + * {@code null}. + * @param members An optional field for additional members. Any {@code null} * values will be ignored. * @return This object. * diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/ServiceProviderConfigResource.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/ServiceProviderConfigResource.java index 8a9aa7da..b0dd7004 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/ServiceProviderConfigResource.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/ServiceProviderConfigResource.java @@ -62,7 +62,7 @@ * }, * "bulk": { * "supported": true, - * "maxOperations": 1000, + * "maxOperations": 100, * "maxPayloadSize": 1048576 * }, * "filter": { @@ -104,7 +104,7 @@ * The above JSON response indicates that this SCIM service: *
      *
    • Supports SCIM PATCH requests. - *
    • Supports SCIM bulk requests with up to 1000 operations in a request. + *
    • Supports SCIM bulk requests with up to 100 operations in a request. *
    • Supports SCIM filtering and will return a maximum of 200 results. *
    • Supports password change API requests. *
    • Supports sorting the result set when multiple resources are returned. diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/ApiConstants.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/ApiConstants.java index 868fb5c1..9cbc70d3 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/ApiConstants.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/ApiConstants.java @@ -144,4 +144,12 @@ public class ApiConstants */ @NotNull public static final String QUERY_PARAMETER_PAGE_SIZE = "count"; + + /** + * An identifier that may be used by a bulk operation to reference another + * operation within the same bulk request. For more information, see + * {@link com.unboundid.scim2.common.bulk.BulkOperation BulkOperation}. + */ + @NotNull + public static final String BULK_PREFIX = "bulkId:"; } diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/BulkStatusDeserializer.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/BulkStatusDeserializer.java new file mode 100644 index 00000000..5faa6f29 --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/BulkStatusDeserializer.java @@ -0,0 +1,101 @@ +/* + * 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.utils; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.unboundid.scim2.common.annotations.NotNull; +import com.unboundid.scim2.common.annotations.Nullable; + +import java.io.IOException; + +/** + * Deserializer for the {@code status} field of a bulk operation contained + * within a bulk response. The primary form is: + *
      + *   {
      + *     "status": "200"
      + *   }
      + * 
      + * + * However, a single reference in the RFC displays the following form, which is + * likely an artifact from the SCIM 1.1 standard that is not used: + *
      + *   {
      + *     "status": {
      + *       "code": "200"
      + *     }
      + *   }
      + * 
      + * + * To ensure broader compatibility, this deserializer looks for both forms when + * parsing the {@code status} value. Note that when the SCIM SDK serializes + * objects into JSON, it always prints strings of the first form. + */ +public class BulkStatusDeserializer extends JsonDeserializer +{ + /** + * Implementation of the bulk status deserializer. See the class-level Javadoc + * for more information. + *

      + * + * {@inheritDoc} + */ + @Override + @NotNull + public String deserialize(@NotNull final JsonParser p, + @Nullable final DeserializationContext ctxt) + throws IOException + { + final JsonNode statusNode = JsonUtils.getObjectReader().readTree(p); + + // Check for { "status": "200" }. + if (statusNode.isTextual()) + { + return statusNode.asText(); + } + + // Check for the "status.code" sub-attribute. + JsonNode nested = statusNode.path("code"); + if (nested.isTextual()) + { + return nested.asText(); + } + + throw new IOException( + "Could not parse the 'status' field of the bulk operation response." + ); + } +} diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkResourceMapperTest.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkResourceMapperTest.java new file mode 100644 index 00000000..bcf16dcb --- /dev/null +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkResourceMapperTest.java @@ -0,0 +1,131 @@ +/* + * 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.databind.node.NullNode; +import com.unboundid.scim2.common.BaseScimResource; +import com.unboundid.scim2.common.GenericScimResource; +import com.unboundid.scim2.common.ScimResource; +import com.unboundid.scim2.common.annotations.Schema; +import com.unboundid.scim2.common.utils.JsonUtils; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.Test; + +import java.util.Set; + +import static com.unboundid.scim2.common.bulk.BulkResourceMapper.SCHEMAS_MAP; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/** + * Unit tests for the {@link BulkResourceMapper} class. This functionality is + * also exercised in {@code EndpointTestCase#testBulkRequestJsonProcessing}. + */ +public class BulkResourceMapperTest +{ + /** + * Reset the bulk resource mapper to its default settings. + */ + @AfterMethod + public void tearDown() + { + BulkResourceMapper.initialize(); + } + + /** + * Basic validation for the resource mapper. + */ + @Test + public void testBasic() + { + // Clear the map to start. + BulkResourceMapper.clear(); + + // Call the add() method and ensure it is registered appropriately for a + // class defined with the @Schema annotation. + BulkResourceMapper.add(ClassWithAnnotation.class); + assertThat(SCHEMAS_MAP).hasSize(1); + + // Now that the value is present in the map, querying the map for a schema + // list of "urn:pingidentity:example" should return the correct class. + Class clazz = + BulkResourceMapper.get(Set.of("urn:pingidentity:example")); + assertThat(clazz).isEqualTo(ClassWithAnnotation.class); + + // Query again with a JSON node. + var arrayNode = JsonUtils.getJsonNodeFactory().arrayNode() + .add("urn:pingidentity:example"); + clazz = BulkResourceMapper.get(arrayNode); + assertThat(clazz).isEqualTo(ClassWithAnnotation.class); + + // A class that is not annotated with @Schema is not compatible with add() + // since there is no information to fetch. + assertThatThrownBy(() -> BulkResourceMapper.add(NoAnnotation.class)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Requested schema for the") + .hasMessageContaining("NoAnnotation class, which does not have a valid") + .hasMessageContaining("@Schema annotation"); + + // It should be possible to register the un-annotated class with the put() + // method. + BulkResourceMapper.clear(); + BulkResourceMapper.put(Set.of("urn:pingidentity:put"), NoAnnotation.class); + assertThat(SCHEMAS_MAP).hasSize(1); + + arrayNode.removeAll(); + arrayNode.add("urn:pingidentity:put"); + Class clazzNoAnnotation = BulkResourceMapper.get(arrayNode); + assertThat(clazzNoAnnotation).isEqualTo(NoAnnotation.class); + + // Trying to fetch an unregistered or invalid JsonNode should just return + // the default GenericScimResource value. + arrayNode.removeAll(); + arrayNode.add("urn:notFound"); + assertThat(BulkResourceMapper.get(arrayNode)) + .isEqualTo(GenericScimResource.class); + assertThat(BulkResourceMapper.get(NullNode.getInstance())) + .isEqualTo(GenericScimResource.class); + } + + /** + * A custom class definition with a {@code @Schema} annotation. + */ + @Schema(id = "urn:pingidentity:example", description = "", name = "") + private static class ClassWithAnnotation extends BaseScimResource {} + + /** + * A custom class definition without a {@code @Schema} annotation. + */ + private static class NoAnnotation extends BaseScimResource {} +} diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/exceptions/ScimExceptionTest.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/exceptions/ScimExceptionTest.java index 9dce1bb7..48492343 100644 --- a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/exceptions/ScimExceptionTest.java +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/exceptions/ScimExceptionTest.java @@ -35,11 +35,13 @@ import com.unboundid.scim2.common.messages.ErrorResponse; import org.testng.annotations.Test; +import javax.naming.SizeLimitExceededException; import java.net.ConnectException; import java.nio.BufferOverflowException; import java.nio.file.AccessDeniedException; import java.sql.SQLClientInfoException; import java.text.ParseException; +import java.util.concurrent.TimeoutException; import static org.assertj.core.api.Assertions.assertThat; @@ -79,6 +81,10 @@ public void testScimException() assertThat(e).isInstanceOf(ResourceConflictException.class); e = ScimException.createException(new ErrorResponse(412), null); assertThat(e).isInstanceOf(PreconditionFailedException.class); + e = ScimException.createException(new ErrorResponse(413), null); + assertThat(e).isInstanceOf(ContentTooLargeException.class); + e = ScimException.createException(new ErrorResponse(429), null); + assertThat(e).isInstanceOf(RateLimitException.class); e = ScimException.createException(new ErrorResponse(500), null); assertThat(e).isInstanceOf(ServerErrorException.class); e = ScimException.createException(new ErrorResponse(501), null); @@ -94,6 +100,9 @@ public void testScimException() public void testNotModifiedException() { final int errorCode = 304; + assertThat(errorCode).isEqualTo(NotModifiedException.statusInt()); + assertThat(errorCode) + .asString().isEqualTo(NotModifiedException.status()); NotModifiedException e = new NotModifiedException("The resource has not been modified."); @@ -129,6 +138,9 @@ public void testNotModifiedException() public void testBadRequestException() { final int errorCode = 400; + assertThat(errorCode).isEqualTo(BadRequestException.statusInt()); + assertThat(errorCode) + .asString().isEqualTo(BadRequestException.status()); BadRequestException e = new BadRequestException("Detailed message explaining the error."); @@ -182,6 +194,9 @@ public void testBadRequestException() public void testUnauthorizedException() { final int errorCode = 401; + assertThat(errorCode).isEqualTo(UnauthorizedException.statusInt()); + assertThat(errorCode) + .asString().isEqualTo(UnauthorizedException.status()); UnauthorizedException e = new UnauthorizedException( "The client is not authorized to perform the operation."); @@ -213,6 +228,9 @@ public void testUnauthorizedException() public void testForbiddenException() { final int errorCode = 403; + assertThat(errorCode).isEqualTo(ForbiddenException.statusInt()); + assertThat(errorCode) + .asString().isEqualTo(ForbiddenException.status()); ForbiddenException e = new ForbiddenException("Access denied."); assertThat(e.getScimError().getStatus()).isEqualTo(errorCode); @@ -249,6 +267,9 @@ public void testForbiddenException() public void testNotFoundException() { final int errorCode = 404; + assertThat(errorCode).isEqualTo(ResourceNotFoundException.statusInt()); + assertThat(errorCode) + .asString().isEqualTo(ResourceNotFoundException.status()); ResourceNotFoundException e = new ResourceNotFoundException("The requested resource was not found."); @@ -271,6 +292,9 @@ public void testNotFoundException() public void testMethodNotAllowedException() { final int errorCode = 405; + assertThat(errorCode).isEqualTo(MethodNotAllowedException.statusInt()); + assertThat(errorCode) + .asString().isEqualTo(MethodNotAllowedException.status()); MethodNotAllowedException e = new MethodNotAllowedException( "The /.search endpoint only supports POST requests."); @@ -295,6 +319,9 @@ public void testMethodNotAllowedException() public void testResourceConflictException() { final int errorCode = 409; + assertThat(errorCode).isEqualTo(ResourceConflictException.statusInt()); + assertThat(errorCode) + .asString().isEqualTo(ResourceConflictException.status()); ResourceConflictException e = new ResourceConflictException("Detailed error message."); @@ -332,6 +359,9 @@ public void testResourceConflictException() public void testPreconditionFailedException() { final int errorCode = 412; + assertThat(errorCode).isEqualTo(PreconditionFailedException.statusInt()); + assertThat(errorCode) + .asString().isEqualTo(PreconditionFailedException.status()); PreconditionFailedException e = new PreconditionFailedException( "Failed to update. The resource changed on the server."); @@ -373,6 +403,68 @@ public void testPreconditionFailedException() assertThat(e.getVersion()).isEqualTo("serverVersion"); } + /** + * Tests for {@link ContentTooLargeException}. + */ + @Test + public void testContentTooLargeException() + { + final int errorCode = 413; + assertThat(errorCode).isEqualTo(ContentTooLargeException.statusInt()); + assertThat(errorCode) + .asString().isEqualTo(ContentTooLargeException.status()); + + ContentTooLargeException e = + new ContentTooLargeException("The request size exceeds the limit."); + assertThat(e.getScimError().getStatus()).isEqualTo(errorCode); + assertThat(e.getScimError().getScimType()).isNull(); + assertThat(e.getMessage()).isEqualTo("The request size exceeds the limit."); + assertThat(e.getCause()).isNull(); + + e = new ContentTooLargeException("Size limit exceeded", + new SizeLimitExceededException()); + assertThat(e.getScimError().getStatus()).isEqualTo(errorCode); + assertThat(e.getScimError().getScimType()).isNull(); + assertThat(e.getMessage()).isEqualTo("Size limit exceeded"); + assertThat(e.getCause()).isInstanceOf(SizeLimitExceededException.class); + + var errorResponse = new ErrorResponse(errorCode); + errorResponse.setDetail("Limit exceeded."); + e = new ContentTooLargeException(errorResponse, + new NegativeArraySizeException()); + assertThat(e.getScimError().getStatus()).isEqualTo(errorCode); + assertThat(e.getScimError().getScimType()).isNull(); + assertThat(e.getMessage()).isEqualTo("Limit exceeded."); + assertThat(e.getCause()).isInstanceOf(NegativeArraySizeException.class); + } + + /** + * Tests for {@link RateLimitException}. + */ + @Test + public void testRateLimitException() + { + final int errorCode = 429; + assertThat(errorCode).isEqualTo(RateLimitException.statusInt()); + assertThat(errorCode) + .asString().isEqualTo(RateLimitException.status()); + + RateLimitException e = + new RateLimitException("Too many requests. Please try again later."); + assertThat(e.getScimError().getStatus()).isEqualTo(errorCode); + assertThat(e.getScimError().getScimType()).isNull(); + assertThat(e.getMessage()).contains("Please try again later."); + assertThat(e.getCause()).isNull(); + + var errorResponse = new ErrorResponse(errorCode); + errorResponse.setDetail("You're sending it too fast."); + e = new RateLimitException(errorResponse, new TimeoutException()); + assertThat(e.getScimError().getStatus()).isEqualTo(errorCode); + assertThat(e.getScimError().getScimType()).isNull(); + assertThat(e.getMessage()).isEqualTo("You're sending it too fast."); + assertThat(e.getCause()).isInstanceOf(TimeoutException.class); + } + /** * Tests for {@link ServerErrorException}. */ @@ -380,6 +472,9 @@ public void testPreconditionFailedException() public void testServerErrorException() { final int errorCode = 500; + assertThat(errorCode).isEqualTo(ServerErrorException.statusInt()); + assertThat(errorCode) + .asString().isEqualTo(ServerErrorException.status()); ServerErrorException e = new ServerErrorException("An unexpected error occurred."); @@ -403,6 +498,9 @@ public void testServerErrorException() public void testNotImplementedException() { final int errorCode = 501; + assertThat(errorCode).isEqualTo(NotImplementedException.statusInt()); + assertThat(errorCode) + .asString().isEqualTo(NotImplementedException.status()); NotImplementedException e = new NotImplementedException("The requested endpoint is not supported."); diff --git a/scim2-sdk-server/pom.xml b/scim2-sdk-server/pom.xml index d20d383b..4ddec755 100644 --- a/scim2-sdk-server/pom.xml +++ b/scim2-sdk-server/pom.xml @@ -34,7 +34,7 @@ com.unboundid.product.scim2 scim2-parent - 5.0.1-SNAPSHOT + 5.1.0-SNAPSHOT ../pom.xml scim2-sdk-server diff --git a/scim2-ubid-extensions/pom.xml b/scim2-ubid-extensions/pom.xml index 9564f751..565abc55 100644 --- a/scim2-ubid-extensions/pom.xml +++ b/scim2-ubid-extensions/pom.xml @@ -34,7 +34,7 @@ com.unboundid.product.scim2 scim2-parent - 5.0.1-SNAPSHOT + 5.1.0-SNAPSHOT ../pom.xml scim2-ubid-extensions From 1ce2d43b795c7757b3cff39f5623329b739f5904 Mon Sep 17 00:00:00 2001 From: Khalid Qarryzada Date: Wed, 11 Mar 2026 15:41:27 -0500 Subject: [PATCH 2/4] Add test case for out of order values. --- .../common/bulk/BulkResourceMapperTest.java | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkResourceMapperTest.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkResourceMapperTest.java index bcf16dcb..6ddbf738 100644 --- a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkResourceMapperTest.java +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkResourceMapperTest.java @@ -33,10 +33,14 @@ package com.unboundid.scim2.common.bulk; import com.fasterxml.jackson.databind.node.NullNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.unboundid.scim2.common.BaseScimResource; import com.unboundid.scim2.common.GenericScimResource; +import com.unboundid.scim2.common.Path; import com.unboundid.scim2.common.ScimResource; import com.unboundid.scim2.common.annotations.Schema; +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.AfterMethod; import org.testng.annotations.Test; @@ -118,6 +122,84 @@ public void testBasic() .isEqualTo(GenericScimResource.class); } + /** + * Ensures the mapper returns expected objects when a JsonNode is provided to + * the {@link BulkResourceMapper#asScimResource} method. This is the primary + * way to interface with the BulkResourceMapper. + */ + @Test + public void testJsonNodeConversion() throws Exception + { + final var reader = JsonUtils.getObjectReader().forType(ObjectNode.class); + + // A user JSON should result in a UserResource. + String userJson = """ + { + "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], + "userName": "simpleUser" + }"""; + ObjectNode userNode = reader.readValue(userJson); + ScimResource resource = BulkResourceMapper.asScimResource(userNode); + assertThat(resource).isInstanceOfSatisfying(UserResource.class, + user -> assertThat(user.getUserName()).isEqualTo("simpleUser")); + + // Group JSON objects should result in a GroupResource. + String groupJson = """ + { + "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:Group" ], + "displayName": "simpleGroup" + }"""; + ObjectNode groupNode = reader.readValue(groupJson); + ScimResource groupResource = BulkResourceMapper.asScimResource(groupNode); + assertThat(groupResource).isInstanceOfSatisfying(GroupResource.class, + group -> assertThat(group.getDisplayName()).isEqualTo("simpleGroup")); + + // An unregistered schema should be returned as a GenericScimResource. + String customJson = """ + { + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User", + "urn:example:customExtension" + ], + "userName": "customName" + }"""; + var gen = BulkResourceMapper.asScimResource(reader.readValue(customJson)); + assertThat(gen).isInstanceOfSatisfying(GenericScimResource.class, gsr -> { + assertThat(gsr.getObjectNode().get("userName").asText()) + .isEqualTo("customName"); + }); + + // Register the custom class that was just attempted. + BulkResourceMapper.put( + Set.of("urn:ietf:params:scim:schemas:core:2.0:User", + "urn:example:customExtension"), + UserSubClass.class + ); + + // Attempt reading the value again. + ObjectNode customUser = reader.readValue(customJson); + ScimResource customResource = BulkResourceMapper.asScimResource(customUser); + assertThat(customResource).isInstanceOfSatisfying(UserSubClass.class, + user -> assertThat(user.getUserName()).isEqualTo("customName")); + assertThat(resource).isNotInstanceOf(GenericScimResource.class); + + // The "schemas" value should be treated as a set. Ensure the mapping still + // works when the values are out of order. + String outOfOrderJson = """ + { + "schemas": [ + "urn:example:customExtension", + "urn:ietf:params:scim:schemas:core:2.0:User" + ], + "userName": "customName" + }"""; + ObjectNode outOfOrderUser = reader.readValue(outOfOrderJson); + var outOfOrderResource = BulkResourceMapper.asScimResource(outOfOrderUser); + assertThat(outOfOrderResource).isInstanceOfSatisfying(UserSubClass.class, + user -> assertThat(user.getUserName()).isEqualTo("customName")); + assertThat(outOfOrderResource).isEqualTo(customResource); + } + /** * A custom class definition with a {@code @Schema} annotation. */ @@ -128,4 +210,13 @@ private static class ClassWithAnnotation extends BaseScimResource {} * A custom class definition without a {@code @Schema} annotation. */ private static class NoAnnotation extends BaseScimResource {} + + private static class UserSubClass extends UserResource + { + UserSubClass() + { + super.setSchemaUrns("urn:ietf:params:scim:schemas:core:2.0:User", + "urn:example:customExtension"); + } + } } From 71d67ff031ae04825de9ccfcedf7a46ad70ce192 Mon Sep 17 00:00:00 2001 From: Khalid Qarryzada Date: Wed, 11 Mar 2026 15:44:31 -0500 Subject: [PATCH 3/4] Fix checkstyle. --- .../com/unboundid/scim2/common/bulk/BulkResourceMapperTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkResourceMapperTest.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkResourceMapperTest.java index 6ddbf738..5e25e11a 100644 --- a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkResourceMapperTest.java +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkResourceMapperTest.java @@ -36,7 +36,6 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.unboundid.scim2.common.BaseScimResource; import com.unboundid.scim2.common.GenericScimResource; -import com.unboundid.scim2.common.Path; import com.unboundid.scim2.common.ScimResource; import com.unboundid.scim2.common.annotations.Schema; import com.unboundid.scim2.common.types.GroupResource; From e58d62bd354c0d1ed646f83a53fe4be109653089 Mon Sep 17 00:00:00 2001 From: Khalid Qarryzada Date: Fri, 13 Mar 2026 08:27:37 -0700 Subject: [PATCH 4/4] Review feedback and mapper updates. Updated add() and put() so that the methods are synchronized, offering some level of thread safety for writes to the map that occur during startup. --- .../unboundid/scim2/client/ScimService.java | 3 ++- .../scim2/common/bulk/BulkResourceMapper.java | 7 +++--- .../scim2/common/utils/ApiConstants.java | 24 ++++++++++++------- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/scim2-sdk-client/src/main/java/com/unboundid/scim2/client/ScimService.java b/scim2-sdk-client/src/main/java/com/unboundid/scim2/client/ScimService.java index 25e34e93..2c6d2b9b 100644 --- a/scim2-sdk-client/src/main/java/com/unboundid/scim2/client/ScimService.java +++ b/scim2-sdk-client/src/main/java/com/unboundid/scim2/client/ScimService.java @@ -57,6 +57,7 @@ import jakarta.ws.rs.core.MediaType; import java.net.URI; +import static com.unboundid.scim2.common.utils.ApiConstants.BULK_ENDPOINT; import static com.unboundid.scim2.common.utils.ApiConstants.MEDIA_TYPE_SCIM; import static com.unboundid.scim2.common.utils.ApiConstants.ME_ENDPOINT; import static com.unboundid.scim2.common.utils.ApiConstants.RESOURCE_TYPES_ENDPOINT; @@ -587,7 +588,7 @@ public DeleteRequestBuilder deleteRequest( @NotNull public BulkRequestBuilder bulkRequest() { - return new BulkRequestBuilder(baseTarget.path("Bulk")); + return new BulkRequestBuilder(baseTarget.path(BULK_ENDPOINT)); } /** 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 84846a66..7d6e1267 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 @@ -138,8 +138,9 @@ public class BulkResourceMapper * @throws IllegalArgumentException If the provided class does not have the * required annotation. */ - public static void add(@NotNull final Class clazz) - throws IllegalArgumentException + public static synchronized void add( + @NotNull final Class clazz) + throws IllegalArgumentException { SchemaResource schema; @@ -172,7 +173,7 @@ public static void add(@NotNull final Class clazz) * @param clazz The class type that is associated with the resource type. * @param The returned Java type. */ - public static void put( + public static synchronized void put( @NotNull final Set schemas, @NotNull final Class clazz) { diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/ApiConstants.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/ApiConstants.java index 9cbc70d3..f8833aec 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/ApiConstants.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/utils/ApiConstants.java @@ -79,6 +79,22 @@ public class ApiConstants @NotNull public static final String ME_ENDPOINT = "Me"; + /** + * An HTTP POST to this endpoint is used as a means to consolidate multiple + * write requests into a single API call. For more information, see + * {@link com.unboundid.scim2.common.bulk.BulkRequest BulkRequest}. + */ + @NotNull + public static final String BULK_ENDPOINT = "Bulk"; + + /** + * An identifier that may be used by a bulk operation to reference another + * operation within the same bulk request. For more information, see + * {@link com.unboundid.scim2.common.bulk.BulkOperation BulkOperation}. + */ + @NotNull + public static final String BULK_PREFIX = "bulkId:"; + /** * An HTTP POST to this endpoint is used to retrieve information about * resource schemas supported by a SCIM service provider. @@ -144,12 +160,4 @@ public class ApiConstants */ @NotNull public static final String QUERY_PARAMETER_PAGE_SIZE = "count"; - - /** - * An identifier that may be used by a bulk operation to reference another - * operation within the same bulk request. For more information, see - * {@link com.unboundid.scim2.common.bulk.BulkOperation BulkOperation}. - */ - @NotNull - public static final String BULK_PREFIX = "bulkId:"; }