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..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 @@ -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; @@ -56,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; @@ -575,6 +577,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_ENDPOINT)); + } + + /** + * 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..7d6e1267 --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/bulk/BulkResourceMapper.java @@ -0,0 +1,290 @@ +/* + * 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 synchronized 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 synchronized 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..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. 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..5e25e11a --- /dev/null +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/bulk/BulkResourceMapperTest.java @@ -0,0 +1,221 @@ +/* + * 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.fasterxml.jackson.databind.node.ObjectNode; +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.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; + +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); + } + + /** + * 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. + */ + @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 {} + + private static class UserSubClass extends UserResource + { + UserSubClass() + { + super.setSchemaUrns("urn:ietf:params:scim:schemas:core:2.0:User", + "urn:example:customExtension"); + } + } +} 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