diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c397a41..bc550f19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,15 @@ Updated "remove" patch operations to validate paths when objects are created. Pr only validated when operations were applied. This behavior is specific to non-standard remove operations that set a `value` field, and does not affect most patch operations. +Added support for [RFC 9865](https://datatracker.ietf.org/doc/html/rfc9865), which is an update to +the SCIM 2 standard that defines an alternate cursor-based type of API pagination. This allows SCIM +services to offer an alternative means of returning more results, which is ideal for services that +cannot feasibly implement index-style pagination. This update includes new helper methods, +exception types, documentation, and updates to existing classes that now interface with cursors. +Note that these updates are backward compatible with older API calls, so there are no changes +required for applications that do not require cursor-based pagination. For more background, see the +documentation for the new `PaginationConfig` class, the `ListResponse` class, and the RFC itself. + ## v4.1.0 - 2025-Oct-06 Added new methods to the Path class to simplify certain usages and make interaction, especially instantiation, less verbose. These include: diff --git a/scim2-sdk-client/src/main/java/com/unboundid/scim2/client/SearchResultHandler.java b/scim2-sdk-client/src/main/java/com/unboundid/scim2/client/SearchResultHandler.java index 3cbe38b6..cb3a7928 100644 --- a/scim2-sdk-client/src/main/java/com/unboundid/scim2/client/SearchResultHandler.java +++ b/scim2-sdk-client/src/main/java/com/unboundid/scim2/client/SearchResultHandler.java @@ -34,6 +34,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.unboundid.scim2.common.annotations.NotNull; +import com.unboundid.scim2.common.annotations.Nullable; /** * An interface for handling the search result response. Methods will be called @@ -55,6 +56,26 @@ public interface SearchResultHandler */ void itemsPerPage(final int itemsPerPage); + /** + * Handle the previousCursor in the search response as defined by + * RFC 9865. + * + * @param previousCursor The previousCursor. + * + * @since 5.0.0 + */ + void previousCursor(@Nullable final String previousCursor); + + /** + * Handle the nextCursor in the search response as defined by + * RFC 9865. + * + * @param nextCursor The nextCursor. + * + * @since 5.0.0 + */ + void nextCursor(@Nullable final String nextCursor); + /** * Handle the totalResults in the search response. * diff --git a/scim2-sdk-client/src/main/java/com/unboundid/scim2/client/requests/ListResponseBuilder.java b/scim2-sdk-client/src/main/java/com/unboundid/scim2/client/requests/ListResponseBuilder.java index 614ddcdd..ef28fa54 100644 --- a/scim2-sdk-client/src/main/java/com/unboundid/scim2/client/requests/ListResponseBuilder.java +++ b/scim2-sdk-client/src/main/java/com/unboundid/scim2/client/requests/ListResponseBuilder.java @@ -61,6 +61,20 @@ public class ListResponseBuilder @Nullable private Integer itemsPerPage; + /** + * The optional value identifying the previous page for a service that + * supports cursor-based pagination. + */ + @Nullable + private String previousCursor; + + /** + * The value identifying the next page for a service that supports + * cursor-based pagination. + */ + @Nullable + private String nextCursor; + /** * {@inheritDoc} */ @@ -77,6 +91,22 @@ public void itemsPerPage(final int itemsPerPage) this.itemsPerPage = itemsPerPage; } + /** + * {@inheritDoc} + */ + public void previousCursor(@Nullable final String previousCursor) + { + this.previousCursor = previousCursor; + } + + /** + * {@inheritDoc} + */ + public void nextCursor(@Nullable final String nextCursor) + { + this.nextCursor = nextCursor; + } + /** * {@inheritDoc} */ @@ -96,13 +126,15 @@ public boolean resource(@NotNull final T scimResource) /** * {@inheritDoc} - *

- * This method currently does not perform any action and should not be used. + *

+ * + * This does not perform any action since this class represents a ListResponse + * as defined in the SCIM 2 standard, which does not have a schema extension. */ public void extension(@NotNull final String urn, @NotNull final ObjectNode extensionObjectNode) { - // TODO: do nothing for now + // No implementation required. } /** @@ -115,9 +147,11 @@ public ListResponse build() { return new ListResponse<>( Optional.ofNullable(totalResults).orElse(resources.size()), - resources, startIndex, - itemsPerPage + itemsPerPage, + previousCursor, + nextCursor, + resources ); } } diff --git a/scim2-sdk-client/src/main/java/com/unboundid/scim2/client/requests/SearchRequestBuilder.java b/scim2-sdk-client/src/main/java/com/unboundid/scim2/client/requests/SearchRequestBuilder.java index d32d3214..a8ecd668 100644 --- a/scim2-sdk-client/src/main/java/com/unboundid/scim2/client/requests/SearchRequestBuilder.java +++ b/scim2-sdk-client/src/main/java/com/unboundid/scim2/client/requests/SearchRequestBuilder.java @@ -60,9 +60,11 @@ import java.io.InputStream; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import static com.unboundid.scim2.common.utils.ApiConstants.QUERY_PARAMETER_FILTER; +import static com.unboundid.scim2.common.utils.ApiConstants.QUERY_PARAMETER_PAGE_CURSOR; import static com.unboundid.scim2.common.utils.ApiConstants.QUERY_PARAMETER_PAGE_SIZE; import static com.unboundid.scim2.common.utils.ApiConstants.QUERY_PARAMETER_PAGE_START_INDEX; import static com.unboundid.scim2.common.utils.ApiConstants.QUERY_PARAMETER_SORT_BY; @@ -90,6 +92,9 @@ public class SearchRequestBuilder @Nullable private Integer startIndex; + @Nullable + private String cursor; + @Nullable private Integer count; @@ -122,6 +127,8 @@ public SearchRequestBuilder filter(@Nullable final String filter) * * @param filter the filter object used to request a subset of resources. * @return This builder. + * + * @since 4.0.0 */ @NotNull public SearchRequestBuilder filter(@Nullable final Filter filter) @@ -148,7 +155,11 @@ public SearchRequestBuilder sort(@Nullable final String sortBy, } /** - * Request pagination of resources. + * Request pagination of resources with index-based pagination. + *

+ * + * This type of pagination divides the result set into numeric page numbers. + * For example, to fetch the first page, use a value of {@code 1}. * * @param startIndex the 1-based index of the first query result. * @param count the desired maximum number of query results per page. @@ -163,6 +174,63 @@ public SearchRequestBuilder page(final int startIndex, return this; } + /** + * Request pagination of resources with cursor-based pagination. For more + * information on cursor-based pagination, see {@link ListResponse}. + *

+ * + * For a cursor value of "VZUTiy", this will be translated to a request like: + *

+   *   GET /Users?cursor=VZUTiy&count=10
+   * 
+ * + * To obtain the first page of results (i.e., when a cursor value is not + * known), use the {@link #firstPageCursorWithCount} method, or set + * {@code cursor} to an empty string. + * + * @param cursor The cursor that identifies a page. To request the first page + * of results, this may be an empty string. This value may not + * be {@code null}. + * @param count The desired maximum number of query results per page. + * @return This builder. + * + * @since 5.0.0 + */ + @NotNull + public SearchRequestBuilder pageWithCursor(@NotNull final String cursor, + final int count) + { + // For consistency with the page() method, this value cannot be null. + this.cursor = Objects.requireNonNull(cursor); + this.count = count; + return this; + } + + /** + * Similar to {@link #pageWithCursor}, but requests the first page of + * resources with cursor-based pagination. The SCIM standard defines this as + * a request like: + *
+   *   GET /Users?cursor&count=10
+   * 
+ * + * However, due to the way JAX-RS handles query parameters, this will be + * sent as a key-value pair with an empty value: + *
+   *   GET /Users?cursor=&count=10
+   * 
+ * + * @param count The desired maximum number of query results per page. + * @return This builder. + * + * @since 5.0.0 + */ + @NotNull + public SearchRequestBuilder firstPageCursorWithCount(final int count) + { + return pageWithCursor("", count); + } + /** * {@inheritDoc} */ @@ -186,6 +254,27 @@ WebTarget buildTarget() target = target.queryParam(QUERY_PARAMETER_PAGE_START_INDEX, startIndex); target = target.queryParam(QUERY_PARAMETER_PAGE_SIZE, count); } + // Check the count again since it is possible to use index-based pagination, + // cursor-based pagination, or both. + if (cursor != null && count != null) + { + if (!cursor.isEmpty()) + { + // A specific page is being requested with a cursor. Provide a query + // like "?cursor=value". + target = target.queryParam(QUERY_PARAMETER_PAGE_CURSOR, cursor); + } + else + { + // The first page is being requested with cursor-based pagination. + // Ideally, this should just be "?cursor" as the standard describes. + // Unfortunately, JAX-RS does not appear to support query parameters + // without a value, so we provide "?cursor=" instead. + target = target.queryParam(QUERY_PARAMETER_PAGE_CURSOR, ""); + } + target = target.queryParam(QUERY_PARAMETER_PAGE_SIZE, count); + } + return target; } @@ -202,7 +291,7 @@ public ListResponse invoke(@NotNull final Class cls) throws ScimException { ListResponseBuilder listResponseBuilder = new ListResponseBuilder<>(); - invoke(false, listResponseBuilder, cls); + invoke(listResponseBuilder, cls); return listResponseBuilder.build(); } @@ -236,7 +325,7 @@ public ListResponse invokePost( throws ScimException { ListResponseBuilder listResponseBuilder = new ListResponseBuilder<>(); - invoke(true, listResponseBuilder, cls); + invokePost(listResponseBuilder, cls); return listResponseBuilder.build(); } @@ -303,6 +392,14 @@ private void invoke(final boolean post, case "startindex": resultHandler.startIndex(parser.getIntValue()); break; + case "previouscursor": + // The "previousCursor" value as defined by RFC 9865. + resultHandler.previousCursor(parser.getValueAsString()); + break; + case "nextcursor": + // The "nextCursor" value as defined by RFC 9865. + resultHandler.nextCursor(parser.getValueAsString()); + break; case "itemsperpage": resultHandler.itemsPerPage(parser.getIntValue()); break; @@ -359,8 +456,8 @@ private Response sendPostSearch() } } - SearchRequest searchRequest = new SearchRequest(attributeSet, - excludedAttributeSet, filter, sortBy, sortOrder, startIndex, count); + var searchRequest = new SearchRequest(attributeSet, excludedAttributeSet, + filter, sortBy, sortOrder, startIndex, cursor, count); Invocation.Builder builder = target(). path(ApiConstants.SEARCH_WITH_POST_PATH_EXTENSION). 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 40244a2a..43ea0439 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 @@ -146,6 +146,34 @@ public class BadRequestException extends ScimException @NotNull public static final String INVALID_VERSION = "invalidVersion"; + /** + * The SCIM detailed error keyword that indicates the client's provided + * cursor is invalid. + * + * @since 5.0.0 + */ + @NotNull + public static final String INVALID_CURSOR = "invalidCursor"; + + /** + * The SCIM detailed error keyword that indicates the client's provided + * cursor is expired. + * + * @since 5.0.0 + */ + @NotNull + public static final String EXPIRED_CURSOR = "expiredCursor"; + + /** + * The SCIM detailed error keyword that indicates the client's provided value + * for {@code count} in a + * {@link com.unboundid.scim2.common.messages.SearchRequest} was invalid. + * + * @since 5.0.0 + */ + @NotNull + public static final String INVALID_COUNT = "invalidCount"; + /** * Create a generic BadRequestException without a {@code scimType} field. @@ -340,4 +368,52 @@ public static BadRequestException invalidVersion( { return new BadRequestException(errorMessage, INVALID_VERSION); } + + /** + * Factory method to create a new {@code BadRequestException} with the + * invalidCursor SCIM detailed error keyword. + * + * @param errorMessage The error message for this SCIM exception. + * @return The new {@code BadRequestException}. + * + * @since 5.0.0 + */ + @NotNull + public static BadRequestException invalidCursor( + @Nullable final String errorMessage) + { + return new BadRequestException(errorMessage, INVALID_CURSOR); + } + + /** + * Factory method to create a new {@code BadRequestException} with the + * expiredCursor SCIM detailed error keyword. + * + * @param errorMessage The error message for this SCIM exception. + * @return The new {@code BadRequestException}. + * + * @since 5.0.0 + */ + @NotNull + public static BadRequestException expiredCursor( + @Nullable final String errorMessage) + { + return new BadRequestException(errorMessage, EXPIRED_CURSOR); + } + + /** + * Factory method to create a new {@code BadRequestException} with the + * invalidCount SCIM detailed error keyword. + * + * @param errorMessage The error message for this SCIM exception. + * @return The new {@code BadRequestException}. + * + * @since 5.0.0 + */ + @NotNull + public static BadRequestException invalidCount( + @Nullable final String errorMessage) + { + return new BadRequestException(errorMessage, INVALID_COUNT); + } } diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/messages/ListResponse.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/messages/ListResponse.java index 6da9eb54..576ecf4c 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/messages/ListResponse.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/messages/ListResponse.java @@ -57,10 +57,8 @@ * * A list response can be broken down into pages, where each page contains a * subset of the overall results. Pagination allows the SCIM service provider to - * return reasonably-sized JSON responses and avoid expensive computations. The - * next page of results can be retrieved by leveraging the "startIndex" field, - * which represents the page number. Pagination is not a hard requirement of the - * SCIM 2.0 protocol, so some SCIM services do not support it. + * return reasonably-sized JSON responses and avoid expensive computations. More + * details about pagination are available below. *

* * List responses contain the following fields: @@ -73,51 +71,118 @@ * of {@code itemsPerPage} if all of the matched resources are not * present in the provided {@code Resources} array. *
  • {@code startIndex}: The index indicating the page number, if - * pagination is supported by the SCIM service. + * index-based pagination is supported by the SCIM service. + *
  • {@code nextCursor}: An identifier representing the next page, if + * cursor-based pagination is supported by the SCIM service. + *
  • {@code previousCursor}: An identifier representing the prior page, if + * cursor-based pagination is supported by the SCIM service. * *

    * * An example list response takes the following form: *
      *   {
    - *       "schemas": [ "urn:ietf:params:scim:api:messages:2.0:ListResponse" ],
    - *       "totalResults": 100,
    - *       "itemsPerPage": 1,
    - *       "Resources": [
    - *           {
    - *               "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ],
    - *               "userName": "muhammad.ali",
    - *               "title": "Champ"
    - *           }
    - *       ]
    + *     "schemas": [ "urn:ietf:params:scim:api:messages:2.0:ListResponse" ],
    + *     "totalResults": 100,
    + *     "itemsPerPage": 1,
    + *     "Resources": [
    + *       {
    + *         "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ],
    + *         "userName": "muhammad.ali",
    + *         "title": "Champ"
    + *       }
    + *     ]
      *   }
      * 
    * * To create the above list response, use the following Java code: - *
    + * 
    
      *   UserResource muhammad = new UserResource()
      *           .setUserName("muhammad.ali")
      *           .setTitle("Champ");
      *   ListResponse<UserResource> response =
      *           new ListResponse<>(100, List.of(muhammad), 1, null);
    - * 
    + *
    * - * Any Collection may be passed directly into the alternate constructor. - *
    + * Any Collection may be used directly.
    + * 
    
      *   List<UserResource> list = getUserList();
      *   ListResponse<UserResource> response = new ListResponse<>(list);
    - * 
    + *
    * * When iterating over the elements in a list response's {@code Resources} list, * it is possible to iterate directly over the ListResponse object: - *
    + * 
    
      *   ListResponse<BaseScimResource> listResponse = getResponse();
      *   for (BaseScimResource resource : listResponse)
      *   {
      *     System.out.println(resource.getId());
      *   }
    + * 
    + * + *

    Pagination

    + * + * SCIM services often have a maximum number of results that can be returned in + * a single list response. For API clients that wish to see all of the results, + * the SCIM protocol defines pagination so that results are returned in chunks. + * For more information on pagination, see + * {@link com.unboundid.scim2.common.types.PaginationConfig PaginationConfig}. + *

    + * + * The following is an example JSON from a service that supports cursor-based + * pagination. This response indicates that the next page of results can be + * obtained by including a query parameter of {@code cursor=YkU3OF86Pz0rGv} in + * the next request to the {@code /Users} endpoint. Note that this example SCIM + * service does not return the optional {@code previousCursor} value. When the + * final result is returned, the {@code nextCursor} value will be {@code null}. + *
    + *   {
    + *     "schemas": [ "urn:ietf:params:scim:api:messages:2.0:ListResponse" ],
    + *     "totalResults": 100,
    + *     "itemsPerPage": 1,
    + *     "nextCursor": "YkU3OF86Pz0rGv",
    + *     "Resources": [
    + *       {
    + *         "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ],
    + *         "userName": "muhammad.ali",
    + *         "title": "Champ"
    + *       }
    + *     ]
    + *   }
      * 
    * + * The above list response can be created with the following Java code: + *
    
    + *   ListResponse<UserResource> response =
    + *       new ListResponse<>(100, "YkU3OF86Pz0rGv", 1, List.of(muhammad));
    + * 
    + * + * The following is an example JSON from a service that supports index-based + * pagination. This response indicates the 12th page in the result set. The next + * page of results can be obtained by including a query parameter of + * {@code startIndex=13} in the next request to the {@code /Users} endpoint. + *
    + *   {
    + *     "schemas": [ "urn:ietf:params:scim:api:messages:2.0:ListResponse" ],
    + *     "totalResults": 100,
    + *     "itemsPerPage": 1,
    + *     "startIndex": 12,
    + *     "Resources": [
    + *       {
    + *         "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ],
    + *         "userName": "muhammad.ali",
    + *         "title": "Champ"
    + *       }
    + *     ]
    + *   }
    + * 
    + * + * The above list response can be created with the following Java code: + *
    
    + *   ListResponse<UserResource> response =
    + *       new ListResponse<>(100, List.of(muhammad), 12, 1);
    + * 
    + * *

    Deserializing A Raw JSON

    * * If you have a raw JSON object and need to convert it to a ListResponse, @@ -144,27 +209,34 @@ @Schema(id="urn:ietf:params:scim:api:messages:2.0:ListResponse", name="List Response", description = "SCIM 2.0 List Response") @JsonPropertyOrder({ "schemas", "totalResults", "itemsPerPage", "startIndex", - "Resources" }) + "previousCursor", "nextCursor", "Resources" }) public class ListResponse extends BaseScimResource implements Iterable { @Attribute(description = "The total number of results returned by the " + "list or query operation") - @JsonProperty(value = "totalResults", required = true) private final int totalResults; @Attribute(description = "The number of resources returned in a list " + "response page") @Nullable - @JsonProperty("itemsPerPage") private final Integer itemsPerPage; @Attribute(description = "The 1-based index of the first result in " + "the current set of list results") @Nullable - @JsonProperty("startIndex") private final Integer startIndex; + @Attribute(description = "A cursor value string that MAY be used in a" + + " subsequent request to obtain the previous page of results") + @Nullable + private final String previousCursor; + + @Attribute(description = "A cursor value string that MAY be used in a" + + " subsequent request to obtain the next page of results") + @Nullable + private final String nextCursor; + @Attribute(description = "A multi-valued list of complex objects " + "containing the requested resources") @NotNull @@ -180,30 +252,93 @@ public class ListResponse extends BaseScimResource * @param totalResults The total number of results returned. If there are * more results than on one page, this value can be * larger than the page size. - * @param resources A multi-valued list of SCIM resources representing the - * result set. * @param startIndex The 1-based index of the first result in the current * set of list results. This can be {@code null} if the * SCIM service provider does not support pagination. - * @param itemsPerPage The number of resources returned in the list response + * @param itemPerPage The number of resources returned in the list response * page. + * @param prevCursor The cursor value representing an ID for the previous + * page. Cursor-based pagination is defined in RFC 9865. + * @param nextCursor The cursor value representing an ID for the next page. + * Cursor-based pagination is defined in RFC 9865. + * @param resources A multi-valued list of SCIM resources representing the + * result set. * * @throws IllegalStateException If the {@code resources} list is {@code null} * and {@code totalResults} is non-zero. + * + * @since 5.0.0 */ @JsonCreator public ListResponse( @JsonProperty(value="totalResults", required=true) final int totalResults, - @NotNull @JsonProperty(value = "Resources") final List resources, - @Nullable @JsonProperty("startIndex") final Integer startIndex, - @Nullable @JsonProperty("itemsPerPage") final Integer itemsPerPage) + @Nullable @JsonProperty(value = "startIndex") final Integer startIndex, + @Nullable @JsonProperty(value = "itemsPerPage") final Integer itemPerPage, + @Nullable @JsonProperty(value = "previousCursor") final String prevCursor, + @Nullable @JsonProperty(value = "nextCursor") final String nextCursor, + @NotNull @JsonProperty(value = "Resources") final List resources) throws IllegalStateException { this.totalResults = totalResults; - this.startIndex = startIndex; - this.itemsPerPage = itemsPerPage; - this.resources = - resourcesOrEmptyList(resources, itemsPerPage, totalResults); + this.startIndex = startIndex; + this.itemsPerPage = itemPerPage; + this.previousCursor = prevCursor; + this.nextCursor = nextCursor; + this.resources = resourcesOrEmptyList(resources, itemPerPage, totalResults); + } + + /** + * Create a new list response. This constructor may be used for services that + * use index-based pagination. + * + * @param totalResults The total number of results returned. If there are + * more results than on one page, this value can be + * larger than the page size. + * @param resources A multi-valued list of SCIM resources representing the + * result set. + * @param startIndex The 1-based index of the first result in the current + * set of list results. This can be {@code null} to avoid + * use of index-based pagination. + * @param itemsPerPage The number of resources returned in the list response + * page. + * + * @throws IllegalStateException If the {@code resources} list is {@code null} + * and {@code totalResults} is non-zero. + */ + public ListResponse(final int totalResults, + @NotNull final List resources, + @Nullable final Integer startIndex, + @Nullable final Integer itemsPerPage) + throws IllegalStateException + { + this(totalResults, startIndex, itemsPerPage, null, null, resources); + } + + /** + * Create a new list response. This constructor may be used for services that + * use cursor-based pagination. + * + * @param totalResults The total number of results returned. If there are + * more results than on one page, this value can be + * larger than the page size. + * @param nextCursor The cursor value representing an ID for the next page. + * Cursor-based pagination is defined in RFC 9865. + * @param itemsPerPage The number of resources returned in the list response + * page. + * @param resources A multi-valued list of SCIM resources representing the + * result set. + * + * @throws IllegalStateException If the {@code resources} list is {@code null} + * and {@code totalResults} is non-zero. + * + * @since 5.0.0 + */ + public ListResponse(final int totalResults, + @Nullable final String nextCursor, + @Nullable final Integer itemsPerPage, + @NotNull final List resources) + { + this(totalResults, null, itemsPerPage, null, nextCursor, resources); } /** @@ -220,6 +355,8 @@ public ListResponse(@NotNull final Collection resources) this.resources = new ArrayList<>(resources); this.startIndex = null; this.itemsPerPage = null; + this.previousCursor = null; + this.nextCursor = null; } /** @@ -271,6 +408,42 @@ public Integer getItemsPerPage() return itemsPerPage; } + /** + * Retrieves the previous cursor value, if: + *
      + *
    • The SCIM service supports cursor-based pagination as defined by the + * RFC 9865 + * update to the SCIM 2 standard. + *
    • The SCIM service also supports returning this optional value. It is + * possible for a SCIM service to only support "nextCursor" values. + *
    + * + * @return The previous cursor value that identifies the previous page in the + * result set, or {@code null} if the value is not present. + * + * @since 5.0.0 + */ + @Nullable + public String getPreviousCursor() + { + return previousCursor; + } + + /** + * Retrieves the value of the next cursor. This is relevant for SCIM services + * that support cursor-based pagination as defined by + * RFC 9865. + * + * @return The next cursor representing the next page. + * + * @since 5.0.0 + */ + @Nullable + public String getNextCursor() + { + return nextCursor; + } + /** * {@inheritDoc} */ @@ -317,6 +490,14 @@ public boolean equals(@Nullable final Object o) { return false; } + if (!Objects.equals(previousCursor, that.previousCursor)) + { + return false; + } + if (!Objects.equals(nextCursor, that.nextCursor)) + { + return false; + } return resources.equals(that.resources); } @@ -329,7 +510,7 @@ public boolean equals(@Nullable final Object o) public int hashCode() { return Objects.hash(super.hashCode(), totalResults, itemsPerPage, - startIndex, resources); + startIndex, previousCursor, nextCursor, resources); } /** diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/messages/SearchRequest.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/messages/SearchRequest.java index 7701e7cc..8189d974 100644 --- a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/messages/SearchRequest.java +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/messages/SearchRequest.java @@ -43,16 +43,14 @@ import java.util.Objects; import java.util.Set; -import static com.unboundid.scim2.common.utils.ApiConstants.*; - /** * This class represents a SCIM 2.0 search request. *

    * * A SCIM search involves requests to endpoints such as {@code /Users} or - * {@code /Groups}, where multiple results may be returned. When a client sends - * a search request, the HTTP response that they will receive from the SCIM - * service will be a {@link ListResponse}, which will provide a list of + * {@code /Groups}, where multiple results are typically returned. When a client + * sends a search request, the HTTP response that they will receive from the + * SCIM service will be a {@link ListResponse}, which will provide a list of * resources. *

    * @@ -73,7 +71,10 @@ *
  • {@code sortOrder}: The order that the {@code sortBy} parameter is * applied. This may be set to "ascending" (the default) or "descending". *
  • {@code startIndex}: The page number of the ListResponse, if the SCIM - * service provider supports pagination. + * service provider supports this type of pagination. + *
  • {@code cursor}: An alternative to {@code startIndex}, this provides a + * string identifier of a page, if the SCIM service supports this type + * of pagination. *
  • {@code count}: The maximum number of resources to return. * *

    @@ -112,46 +113,100 @@ public class SearchRequest extends BaseScimResource @Attribute(description = "A multi-valued list of strings indicating " + "the names of resource attributes to return in the response overriding " + "the set of attributes that would be returned by default") - @JsonProperty private final Set attributes; @Nullable @Attribute(description = "A multi-valued list of strings indicating " + "the names of resource attributes to be removed from the default set " + "of attributes to return") - @JsonProperty private final Set excludedAttributes; @Nullable @Attribute(description = "The filter string used to request a subset " + "of resources") - @JsonProperty private final String filter; @Nullable @Attribute(description = "A string indicating the attribute whose " + "value shall be used to order the returned responses") - @JsonProperty private final String sortBy; @Nullable @Attribute(description = "A string indicating the order in which the " + "sortBy parameter is applied") - @JsonProperty private final SortOrder sortOrder; @Nullable @Attribute(description = "An integer indicating the 1-based index of " + "the first query result") - @JsonProperty private final Integer startIndex; + @Nullable + @Attribute(description = "A string indicating the unique identifier for " + + "the page") + private final String cursor; + @Nullable @Attribute(description = "An integer indicating the desired maximum " + "number of query results per page") - @JsonProperty private final Integer count; + + /** + * Create a new SearchRequest with the provided parameters. This constructor + * supports the use of the {@code cursor} parameter as defined by + * RFC 9865. + * + * @param attributes A list of strings indicating the names of + * attributes to return in the response, overriding + * the set of attributes that would be returned by + * default. + * @param excludedAttributes A list of strings indicating the names of + * attributes to be removed from the default set of + * returned attributes. + * @param filter A {@link Filter} used to request a subset of + * resources that match a criteria. + * @param sortBy A string indicating the attribute whose value + * shall be used to order the returned responses. + * @param sortOrder The order in which the sortBy parameter is + * applied (ascending or descending). + * @param startIndex The 1-based index indicating the desired page, + * if index-based pagination is used. + * @param cursor The cursor ID indicating the desired page, if + * cursor-based pagination is used. + * @param count The maximum number of query results per page. + * + * @since 5.0.0 + */ + @JsonCreator + public SearchRequest( + @Nullable @JsonProperty(value = "attributes") + final Set attributes, + @Nullable @JsonProperty(value = "excludedAttributes") + final Set excludedAttributes, + @Nullable @JsonProperty(value = "filter") + final String filter, + @Nullable @JsonProperty(value = "sortBy") + final String sortBy, + @Nullable @JsonProperty(value = "sortOrder") + final SortOrder sortOrder, + @Nullable @JsonProperty(value = "startIndex") + final Integer startIndex, + @Nullable @JsonProperty(value = "cursor") + final String cursor, + @Nullable @JsonProperty(value = "count") + final Integer count) + { + this.attributes = attributes; + this.excludedAttributes = excludedAttributes; + this.filter = filter; + this.sortBy = sortBy; + this.sortOrder = sortOrder; + this.startIndex = startIndex; + this.cursor = cursor; + this.count = count; + } + /** * Create a new SearchRequest. * @@ -168,29 +223,22 @@ public class SearchRequest extends BaseScimResource * @param startIndex the 1-based index of the first query result. * @param count the desired maximum number of query results per page. */ - @JsonCreator - public SearchRequest(@Nullable @JsonProperty(QUERY_PARAMETER_ATTRIBUTES) - final Set attributes, - @Nullable @JsonProperty(QUERY_PARAMETER_EXCLUDED_ATTRIBUTES) - final Set excludedAttributes, - @Nullable @JsonProperty(QUERY_PARAMETER_FILTER) - final String filter, - @Nullable @JsonProperty(QUERY_PARAMETER_SORT_BY) - final String sortBy, - @Nullable @JsonProperty(QUERY_PARAMETER_SORT_ORDER) - final SortOrder sortOrder, - @Nullable @JsonProperty(QUERY_PARAMETER_PAGE_START_INDEX) - final Integer startIndex, - @Nullable @JsonProperty(QUERY_PARAMETER_PAGE_SIZE) - final Integer count) + public SearchRequest(@Nullable final Set attributes, + @Nullable final Set excludedAttributes, + @Nullable final String filter, + @Nullable final String sortBy, + @Nullable final SortOrder sortOrder, + @Nullable final Integer startIndex, + @Nullable final Integer count) { - this.attributes = attributes; - this.excludedAttributes = excludedAttributes; - this.filter = filter; - this.sortBy = sortBy; - this.sortOrder = sortOrder; - this.startIndex = startIndex; - this.count = count; + this(attributes, + excludedAttributes, + filter, + sortBy, + sortOrder, + startIndex, + null, + count); } /** @@ -268,6 +316,20 @@ public Integer getStartIndex() return startIndex; } + /** + * Retrieves the cursor that identifies the desired page. + * + * @return The cursor that identifies the page, or {@code null} if pagination + * is not required. + * + * @since 5.0.0 + */ + @Nullable + public String getCursor() + { + return cursor; + } + /** * Retrieves the desired maximum number of query results per page. * @@ -328,6 +390,10 @@ public boolean equals(@Nullable final Object o) { return false; } + if (!Objects.equals(cursor, that.cursor)) + { + return false; + } return Objects.equals(count, that.count); } @@ -340,6 +406,6 @@ public boolean equals(@Nullable final Object o) public int hashCode() { return Objects.hash(super.hashCode(), attributes, excludedAttributes, - filter, sortBy, sortOrder, startIndex, count); + filter, sortBy, sortOrder, startIndex, cursor, count); } } diff --git a/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/PaginationConfig.java b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/PaginationConfig.java new file mode 100644 index 00000000..58d08815 --- /dev/null +++ b/scim2-sdk-common/src/main/java/com/unboundid/scim2/common/types/PaginationConfig.java @@ -0,0 +1,318 @@ +/* + * Copyright 2025 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 2025 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.types; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.unboundid.scim2.common.annotations.Attribute; +import com.unboundid.scim2.common.annotations.Nullable; + +import java.util.Objects; + + +/** + * This class represents a complex type that specifies pagination configuration + * of a SCIM service, as defined by + * RFC 9865 + * Section 4. + *

    + * + * Pagination is a useful optimization used in many types of APIs, which breaks + * down large sets of results into manageable pieces referred to as "pages". + * Without pagination, many restrictions and limitations would need to be + * considered when obtaining multiple resources. For example, there are limits + * to how much data can be placed in a JSON payload, which can cause problems + * with HTTP infrastructure rejecting messages that are too large. Furthermore, + * generating massive amounts of data increases processing time, resulting in + * latency that is often undesirable. Pagination allows services and clients to + * easily handle large amounts of data efficiently. + *

    + * + * There are two types of pagination defined in the SCIM standard: + *
      + *
    • Index-based pagination: Allows iterating over the result set by page + * number. The first page is identified by a value of {@code 1}. + *
    • Cursor-based pagination: Allows iterating over the result set with + * string identifiers. When a list response is returned, it will include + * a unique cursor corresponding to the next page. The list response may + * optionally include another cursor corresponding to the previous page. + *
    + *

    + * + * Cursor-based pagination was added to the SCIM standard in RFC 9865. Note that + * pagination is not a hard requirement of the SCIM protocol, so some SCIM + * services may support one, both, or neither approaches. + *

    + * + * A single page of results is represented by a + * {@link com.unboundid.scim2.common.messages.ListResponse ListResponse} object. + * The {@code PaginationConfig} data is displayed on a SCIM service's + * {@code /ServiceProviderConfig} endpoint as a way of clarifying service + * behavior regarding paging, and is optional. + *

    + * + * An example representation of this class is shown below: + *
    + * {
    + *     "schemas": [
    + *         "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"
    + *      ],
    + *     ...
    + *     "pagination": {
    + *         "cursor": true,
    + *         "index": true,
    + *         "defaultPaginationMethod": "cursor",
    + *         "defaultPageSize": 100,
    + *         "maxPageSize": 250,
    + *         "cursorTimeout": 3600
    + *     },
    + *     ...
    + * }
    + * 
    + * + * The above configuration describes a SCIM service provider that: + *
      + *
    • Supports both cursor-based and index-based pagination. + *
    • Uses cursor-based pagination by default, unless a client explicitly + * requests an index-based page number. + *
    • Returns up to 100 resources/elements in a page by default. + *
    • Returns a maximum of 250 resources in a page. + *
    • Invalidates cursor strings after 3600 seconds (1 hour). + *
    + * + * @see ServiceProviderConfigResource + * @since 5.0.0 + */ +public class PaginationConfig +{ + @Attribute(description = "A boolean value specifying support of cursor-based" + + " pagination.", + mutability = AttributeDefinition.Mutability.READ_ONLY, + isRequired = true) + private final boolean cursor; + + @Attribute(description = "A boolean value specifying support of index-based" + + " pagination.", + mutability = AttributeDefinition.Mutability.READ_ONLY, + isRequired = true) + private final boolean index; + + @Nullable + @Attribute(description = "A value specifying the default type of pagination" + + " for the SCIM service. Possible values are \"cursor\" and \"index\".") + private final String defaultPaginationMethod; + + @Nullable + @Attribute(description = "An integer value specifying the default number of" + + " results returned by the SCIM service in a page.") + private final Integer defaultPageSize; + + @Nullable + @Attribute(description = "An integer value specifying the maximum number of" + + " results that can be returned by the SCIM service in a page.") + private final Integer maxPageSize; + + @Nullable + @Attribute(description = """ + A value specifying the minimum number of seconds that a cursor is valid \ + between page requests. No value being specified may mean that there is \ + no cursor timeout, or the cursor timeout is not a static duration.""", + mutability = AttributeDefinition.Mutability.READ_ONLY, + isRequired = true) + private final Integer cursorTimeout; + + + /** + * Create a new complex type that defines pagination behavior for a SCIM + * service. + * + * @param cursor If cursor-based pagination is supported. + * @param index If index-based pagination is supported. + * @param defaultMethod The default pagination method. This should be + * "cursor", "index", or {@code null}. + * @param defaultPageSize The default number of resources returned in a page. + * @param maxPageSize The maximum number of resources returned in a page. + * @param cursorTimeout The maximum number of seconds a cursor is valid, if + * the SCIM service sets expiration times for cursors. + */ + public PaginationConfig( + @JsonProperty(value = "cursor", required = true) + final boolean cursor, + @JsonProperty(value = "index", required = true) + final boolean index, + @Nullable @JsonProperty(value = "defaultPaginationMethod") + final String defaultMethod, + @Nullable @JsonProperty(value = "defaultPageSize") + final Integer defaultPageSize, + @Nullable @JsonProperty(value = "maxPageSize") + final Integer maxPageSize, + @Nullable @JsonProperty(value = "cursorTimeout") + final Integer cursorTimeout) + { + this.cursor = cursor; + this.index = index; + this.defaultPaginationMethod = defaultMethod; + this.defaultPageSize = defaultPageSize; + this.maxPageSize = maxPageSize; + this.cursorTimeout = cursorTimeout; + } + + /** + * Indicates whether the SCIM service supports cursor-based pagination as + * defined by RFC 9865. + * + * @return {@code true} if cursor-based pagination is supported, or + * {@code false} if not. + */ + @JsonProperty("cursor") + public boolean supportsCursorPagination() + { + return cursor; + } + + /** + * Indicates whether the SCIM service supports index-based pagination. + * + * @return {@code true} if index-based pagination is supported, or + * {@code false} if not. + */ + @JsonProperty("index") + public boolean supportsIndexPagination() + { + return index; + } + + /** + * Indicates the default pagination method. Valid values are {@code cursor} + * and {@code index}. + * + * @return The default pagination method, or {@code null} if this is not + * defined. + */ + @Nullable + public String getDefaultPaginationMethod() + { + return defaultPaginationMethod; + } + + /** + * Indicates the default page size returned for + * {@link com.unboundid.scim2.common.messages.ListResponse} objects. + * + * @return The default page size, or {@code null} if this is not defined. + */ + @Nullable + public Integer getDefaultPageSize() + { + return defaultPageSize; + } + + /** + * Indicates the maximum page size that will be returned for + * {@link com.unboundid.scim2.common.messages.ListResponse} objects. + * + * @return The maximum page size, or {@code null} if this is not defined. + */ + @Nullable + public Integer getMaxPageSize() + { + return maxPageSize; + } + + /** + * Indicates the time (in seconds) that a cursor generated by the SCIM service + * will be valid. + * + * @return The time-to-live for a cursor, or {@code null} if this is not + * defined. + */ + @Nullable + public Integer getCursorTimeout() + { + return cursorTimeout; + } + + /** + * Indicates whether the provided object is equal to this pagination + * configuration. + * + * @param o The object to compare. + * @return {@code true} if the provided object is equal to this pagination + * configuration, or {@code false} if not. + */ + @Override + public boolean equals(@Nullable final Object o) + { + if (this == o) + { + return true; + } + if (!(o instanceof PaginationConfig that)) + { + return false; + } + + if (cursor != that.cursor) + { + return false; + } + if (index != that.index) + { + return false; + } + if (!Objects.equals(defaultPaginationMethod, that.defaultPaginationMethod)) + { + return false; + } + if (!Objects.equals(defaultPageSize, that.defaultPageSize)) + { + return false; + } + if (!Objects.equals(maxPageSize, that.maxPageSize)) + { + return false; + } + return Objects.equals(cursorTimeout, that.cursorTimeout); + } + + /** + * Retrieves a hash code for this pagination configuration object. + * + * @return A hash code for this pagination configuration object. + */ + @Override + public int hashCode() + { + return Objects.hash(cursor, index, defaultPaginationMethod, defaultPageSize, + maxPageSize, cursorTimeout); + } +} 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 e5b62fd9..cf489495 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 @@ -44,19 +44,80 @@ import java.util.List; import java.util.Objects; + /** - * SCIM provides a schema for representing the service provider's configuration - * identified using the following schema URI: - * "{@code urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig}" + * This class represents a service provider's configuration. + *

    + * + * All SCIM services should have an endpoint similar to + * {@code https://example.com/v2/ServiceProviderConfig}, which indicates + * information about the behavior of the SCIM service and what it supports. An + * example response is shown below: + *
    + * {
    + *   "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
    + *   "documentationUri": "https://example.com/help/scim.html",
    + *   "patch": {
    + *     "supported": true
    + *   },
    + *   "bulk": {
    + *     "supported": true,
    + *     "maxOperations": 1000,
    + *     "maxPayloadSize": 1048576
    + *   },
    + *   "filter": {
    + *     "supported": true,
    + *     "maxResults": 200
    + *   },
    + *   "changePassword": {
    + *     "supported": true
    + *   },
    + *   "sort": {
    + *     "supported": true
    + *   },
    + *   "etag": {
    + *     "supported": true
    + *   },
    + *   "pagination": {
    + *       "cursor": true,
    + *       "index": false
    + *   },
    + *   "authenticationSchemes": [
    + *     {
    + *       "name": "OAuth Bearer Token",
    + *       "description": "Authentication scheme using the OAuth Standard",
    + *       "specUri": "https://datatracker.ietf.org/doc/html/rfc6750",
    + *       "documentationUri": "https://example.com/help/oauth.html",
    + *       "type": "oauthbearertoken",
    + *       "primary": true
    + *     }
    + *   ],
    + *   "meta": {
    + *     "location": "https://example.com/v2/ServiceProviderConfig",
    + *     "resourceType": "ServiceProviderConfig",
    + *     "created": "2015-09-25T00:00:00Z",
    + *     "lastModified": "2025-10-09T00:00:00Z"
    + *   }
    + * }
    + * 
    + * + * 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 filtering and will return a maximum of 200 results. + *
    • Supports password change API requests. + *
    • Supports sorting the result set when multiple resources are returned. + *
    • Supports ETag versioning. For more details, see {@link ETagConfig}. + *
    • Supports paging through results with cursors, but not by page numbers. + *
    • Supports only OAuth 2.0 bearer tokens for authenticating clients. + *
    *

    * - * The Service Provider configuration resource enables a Service - * Provider to discover SCIM specification features in a standardized - * form as well as provide additional implementation details to clients. - * All attributes have a mutability of "{@code readOnly}". Unlike other core - * resources, the "{@code id}" attribute is not required for the Service - * Provider configuration resource. - **/ + * This endpoint provides a summary for the SCIM service's behavior. All + * attributes on this resource type have a mutability of {@code readOnly}. + */ +@SuppressWarnings("JavadocLinkAsPlainText") @Schema(id="urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig", name="Service Provider Config", description = "SCIM 2.0 Service Provider Config Resource") @@ -109,6 +170,12 @@ public class ServiceProviderConfigResource extends BaseScimResource isRequired = true) private final ETagConfig etag; + @Nullable + @Attribute(description = "A complex type that specifies pagination " + + "configuration options.", + mutability = AttributeDefinition.Mutability.READ_ONLY) + private final PaginationConfig pagination; + @NotNull @Attribute(description = "A complex type that specifies supported " + "Authentication Scheme properties.", @@ -128,9 +195,13 @@ public class ServiceProviderConfigResource extends BaseScimResource * @param changePassword A complex type that specifies Change Password * configuration options. * @param sort A complex type that specifies Sort configuration options. - * @param etag A complex type that specifies Etag configuration options. + * @param etag A complex type that specifies ETag configuration options. + * @param pagination A complex type that specifies pagination configuration + * options. * @param authenticationSchemes A complex type that specifies supported * Authentication Scheme properties. + * + * @since 5.0.0 */ @JsonCreator public ServiceProviderConfigResource( @@ -148,6 +219,8 @@ public ServiceProviderConfigResource( final SortConfig sort, @NotNull @JsonProperty(value = "etag", required = true) final ETagConfig etag, + @Nullable @JsonProperty(value = "pagination") + final PaginationConfig pagination, @NotNull @JsonProperty(value = "authenticationSchemes", required = true) final List authenticationSchemes) { @@ -158,10 +231,86 @@ public ServiceProviderConfigResource( this.changePassword = changePassword; this.sort = sort; this.etag = etag; + this.pagination = pagination; this.authenticationSchemes = authenticationSchemes == null ? null : Collections.unmodifiableList(authenticationSchemes); } + /** + * Alternate constructor that allows specifying authentication schemes + * directly. + * + * @param documentationUri An HTTP addressable URI pointing to the service + * provider's human consumable help documentation. + * @param patch A complex type indicating PATCH configuration + * options. + * @param bulk A complex type indicating Bulk configuration + * options. + * @param filter A complex type indicating filter options. + * @param changePassword A complex type indicating password changing + * configuration options. + * @param sort A complex type indicating Sort configuration + * options. + * @param etag A complex type indicating ETag configuration + * options. + * @param pagination A complex type indicating pagination configuration + * options. + * @param authenticationSchemes A complex type indicating supported + * Authentication Scheme properties. + * + * @since 5.0.0 + */ + public ServiceProviderConfigResource( + @Nullable final String documentationUri, + @NotNull final PatchConfig patch, + @NotNull final BulkConfig bulk, + @NotNull final FilterConfig filter, + @NotNull final ChangePasswordConfig changePassword, + @NotNull final SortConfig sort, + @NotNull final ETagConfig etag, + @Nullable final PaginationConfig pagination, + @NotNull final AuthenticationScheme... authenticationSchemes) + { + this(documentationUri, patch, bulk, filter, changePassword, sort, etag, + pagination, List.of(authenticationSchemes)); + } + + /** + * Create a new ServiceProviderConfig resource. This constructor primarily + * exists for backward compatibility, and using the primary constructor + * ({@link #ServiceProviderConfigResource(String, PatchConfig, BulkConfig, + * FilterConfig, ChangePasswordConfig, SortConfig, ETagConfig, + * PaginationConfig, List) ServiceProviderConfigResource()} + * ) + * is encouraged. The primary constructor supports information regarding + * pagination. + * + * @param documentationUri An HTTP addressable URI pointing to the service + * provider's human consumable help documentation. + * @param patch A complex type that specifies PATCH configuration options. + * @param bulk A complex type that specifies Bulk configuration options. + * @param filter A complex type that specifies FILTER options. + * @param changePassword A complex type that specifies Change Password + * configuration options. + * @param sort A complex type that specifies Sort configuration options. + * @param etag A complex type that specifies ETag configuration options. + * @param authenticationSchemes A complex type that specifies supported + * Authentication Scheme properties. + */ + public ServiceProviderConfigResource( + @Nullable final String documentationUri, + @NotNull final PatchConfig patch, + @NotNull final BulkConfig bulk, + @NotNull final FilterConfig filter, + @NotNull final ChangePasswordConfig changePassword, + @NotNull final SortConfig sort, + @NotNull final ETagConfig etag, + @NotNull final List authenticationSchemes) + { + this(documentationUri, patch, bulk, filter, changePassword, sort, etag, + null, authenticationSchemes); + } + /** * Retrieves the HTTP addressable URI pointing to the service provider's * human consumable help documentation. @@ -233,9 +382,9 @@ public SortConfig getSort() } /** - * Retrieves the complex type that specifies Etag configuration options. + * Retrieves the complex type that specifies ETag configuration options. * - * @return The complex type that specifies Etag configuration options. + * @return The complex type that specifies ETag configuration options. */ @NotNull public ETagConfig getEtag() @@ -243,6 +392,19 @@ public ETagConfig getEtag() return etag; } + /** + * Retrieves the complex type that specifies pagination configuration options. + * This may be {@code null} for SCIM services that do not explicitly support + * RFC 9865. + * + * @return The complex type that specifies pagination configuration options. + */ + @Nullable + public PaginationConfig getPagination() + { + return pagination; + } + /** * Retrieves the complex type that specifies supported Authentication Scheme * properties. @@ -309,6 +471,10 @@ public boolean equals(@Nullable final Object o) { return false; } + if (!Objects.equals(pagination, that.pagination)) + { + return false; + } return Objects.equals(sort, that.sort); } @@ -321,6 +487,6 @@ public boolean equals(@Nullable final Object o) public int hashCode() { return Objects.hash(super.hashCode(), documentationUri, patch, bulk, filter, - changePassword, sort, etag, authenticationSchemes); + changePassword, sort, etag, pagination, authenticationSchemes); } } 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 1a19d780..6cbfb85e 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 @@ -130,6 +130,14 @@ public class ApiConstants @NotNull public static final String QUERY_PARAMETER_PAGE_START_INDEX = "startIndex"; + /** + * The HTTP query parameter used in a URI to specify a cursor identifier for + * page results. See {@link com.unboundid.scim2.common.types.PaginationConfig} + * for more information. + */ + @NotNull + public static final String QUERY_PARAMETER_PAGE_CURSOR = "cursor"; + /** * The HTTP query parameter used in a URI to specify the maximum size of * a page of results. diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/ListResponseTestCase.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/ListResponseTestCase.java index 264c10dd..769f4d4f 100644 --- a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/ListResponseTestCase.java +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/ListResponseTestCase.java @@ -37,6 +37,7 @@ import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.ObjectReader; import com.unboundid.scim2.common.messages.ListResponse; +import com.unboundid.scim2.common.types.GroupResource; import com.unboundid.scim2.common.types.ResourceTypeResource; import com.unboundid.scim2.common.types.UserResource; import com.unboundid.scim2.common.utils.JsonUtils; @@ -66,7 +67,10 @@ public class ListResponseTestCase "itemsPerPage": 1, "startIndex": 1, "Resources": [ - "stringValue" + { + "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], + "userName": "Frieren" + } ] }"""; @@ -117,8 +121,8 @@ public void testListResponseFormat() throws Exception JsonUtils.getObjectReader().readTree(SINGLE_ELEMENT_LIST_RESPONSE) .toString(); - List resources = Collections.singletonList("stringValue"); - ListResponse listResponse = new ListResponse<>(2, resources, 1, 1); + var list = List.of(new UserResource().setUserName("Frieren")); + ListResponse listResponse = new ListResponse<>(2, list, 1, 1); String listResponseJSON = JsonUtils.getObjectWriter().writeValueAsString(listResponse); @@ -126,6 +130,61 @@ public void testListResponseFormat() throws Exception } + /** + * Test the other utility constructors. + */ + @Test + public void testAlternateConstructors() + { + // This constructor should set totalResults and Resources, and leave all + // other fields null. + var group = new GroupResource().setDisplayName("Frieren's Party"); + ListResponse collection = new ListResponse<>(List.of(group)); + + assertThat(collection.getResources()) + .hasSize(1) + .containsExactly(group); + assertThat(collection.getTotalResults()).isEqualTo(1); + assertThat(collection.getStartIndex()).isNull(); + assertThat(collection.getItemsPerPage()).isNull(); + assertThat(collection.getPreviousCursor()).isNull(); + assertThat(collection.getNextCursor()).isNull(); + + // Use the original constructor from before RFC 9865 was supported. + ListResponse groupResponse = + new ListResponse<>(1, List.of(group), null, null); + assertThat(groupResponse).isEqualTo(collection); + + // This constructor is primarily used for SCIM services that only support + // cursor-based pagination and do not need to return the previous cursor. + ListResponse cursorResponse = + new ListResponse<>(2, "cursorValue", 1, List.of(group)); + + assertThat(cursorResponse.getResources()) + .hasSize(1) + .containsExactly(group); + assertThat(cursorResponse.getTotalResults()).isEqualTo(2); + assertThat(cursorResponse.getStartIndex()).isNull(); + assertThat(cursorResponse.getItemsPerPage()).isEqualTo(1); + assertThat(cursorResponse.getPreviousCursor()).isNull(); + assertThat(cursorResponse.getNextCursor()).isEqualTo("cursorValue"); + + // Create a "cursor" response that does not return a "nextCursor" value + // since all results have been returned. + ListResponse allReturnedResponse = + new ListResponse<>(1, null, null, List.of(group)); + + assertThat(allReturnedResponse.getResources()) + .hasSize(1) + .containsExactly(group); + assertThat(allReturnedResponse.getTotalResults()).isEqualTo(1); + assertThat(allReturnedResponse.getStartIndex()).isNull(); + assertThat(allReturnedResponse.getItemsPerPage()).isNull(); + assertThat(allReturnedResponse.getPreviousCursor()).isNull(); + assertThat(allReturnedResponse.getNextCursor()).isNull(); + } + + /** * Ensures that it is possible to deserialize a JSON string properly. This * test ensures that the objects contained within the {@code Resources} array @@ -160,6 +219,8 @@ public void testDeserialization() throws Exception assertThat(response.getTotalResults()).isEqualTo(2); assertThat(response.getItemsPerPage()).isEqualTo(2); assertThat(response.getStartIndex()).isNull(); + assertThat(response.getPreviousCursor()).isNull(); + assertThat(response.getNextCursor()).isNull(); // The use of a TypeReference object must result in UserResource objects // in the Resources list. Otherwise, attempts to use these objects will @@ -269,6 +330,8 @@ public void testDeserializingNullResourcesArray() throws Exception assertThat(object.getTotalResults()).isEqualTo(0); assertThat(object.getItemsPerPage()).isEqualTo(0); assertThat(object.getStartIndex()).isNull(); + assertThat(object.getPreviousCursor()).isNull(); + assertThat(object.getNextCursor()).isNull(); assertThat(object.getResources()) .isNotNull() .isEmpty(); @@ -284,6 +347,8 @@ public void testDeserializingNullResourcesArray() throws Exception assertThat(small.getTotalResults()).isEqualTo(0); assertThat(small.getItemsPerPage()).isNull(); assertThat(small.getStartIndex()).isNull(); + assertThat(small.getPreviousCursor()).isNull(); + assertThat(small.getNextCursor()).isNull(); assertThat(small.getResources()) .isNotNull() .isEmpty(); @@ -299,6 +364,8 @@ public void testDeserializingNullResourcesArray() throws Exception assertThat(response.getTotalResults()).isEqualTo(0); assertThat(response.getItemsPerPage()).isNull(); assertThat(response.getStartIndex()).isNull(); + assertThat(response.getPreviousCursor()).isNull(); + assertThat(response.getNextCursor()).isNull(); assertThat(response.getResources()) .isNotNull() .isEmpty(); @@ -328,6 +395,8 @@ public void testDeserializingNullResourcesArray() throws Exception assertThat(response2.getTotalResults()).isEqualTo(100); assertThat(response2.getItemsPerPage()).isEqualTo(0); assertThat(response2.getStartIndex()).isNull(); + assertThat(response2.getPreviousCursor()).isNull(); + assertThat(response2.getNextCursor()).isNull(); assertThat(response2.getResources()) .isNotNull() .isEmpty(); @@ -346,8 +415,113 @@ public void testDeserializingNullResourcesArray() throws Exception assertThat(response3.getTotalResults()).isEqualTo(100); assertThat(response3.getItemsPerPage()).isEqualTo(0); assertThat(response3.getStartIndex()).isNull(); + assertThat(response3.getPreviousCursor()).isNull(); + assertThat(response3.getNextCursor()).isNull(); assertThat(response3.getResources()) .isNotNull() .isEmpty(); } + + /** + * Validates support for cursor-based pagination workflows as defined by + * RFC 9865. + */ + @Test + public void testCursorPagination() throws Exception + { + String json = """ + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:ListResponse" ], + "totalResults": 2, + "itemsPerPage": 2, + "previousCursor": "ze7L30kMiiLX6x", + "nextCursor": "YkU3OF86Pz0rGv", + "Resources": [ { + "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], + "id": "226537a7-90bb-4644-9bd0-e2c998e00d66", + "userName": "Frieren" + }, { + "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], + "id": "0fc9de62-645d-4655-9263-1b1a1d6dde13", + "userName": "Fern" + } ] + }"""; + + // Convert the string to a Java object as described by ListResponse's + // class-level Javadoc. + ListResponse response = JsonUtils.getObjectReader() + .forType(new TypeReference>(){}) + .readValue(json); + + // Ensure the fields were converted correctly. + assertThat(response.getTotalResults()).isEqualTo(2); + assertThat(response.getItemsPerPage()).isEqualTo(2); + assertThat(response.getStartIndex()).isNull(); + assertThat(response.getPreviousCursor()).isEqualTo("ze7L30kMiiLX6x"); + assertThat(response.getNextCursor()).isEqualTo("YkU3OF86Pz0rGv"); + + // Ensure it is possible to construct the same object. + UserResource frieren = new UserResource().setUserName("Frieren"); + frieren.setId("226537a7-90bb-4644-9bd0-e2c998e00d66"); + UserResource fern = new UserResource().setUserName("Fern"); + fern.setId("0fc9de62-645d-4655-9263-1b1a1d6dde13"); + + ListResponse constructed = new ListResponse<>( + 2, null, 2, "ze7L30kMiiLX6x", "YkU3OF86Pz0rGv", List.of(frieren, fern)); + assertThat(constructed).isEqualTo(response); + + // The constructed object should be serialized into a consistent form, with + // an expected order for the attributes. First reformat the JSON into a + // non-pretty string. + final String expectedJSON = JsonUtils.getObjectReader() + .readTree(json).toString(); + String serial = JsonUtils.getObjectWriter().writeValueAsString(response); + assertThat(serial).isEqualTo(expectedJSON); + } + + /** + * Tests for {@code equals()}. + */ + @SuppressWarnings("all") + @Test + public void testEquals() + { + ListResponse list = + new ListResponse<>(List.of(new UserResource())); + assertThat(list.equals(list)).isTrue(); + assertThat(list.equals(null)).isFalse(); + assertThat(list.equals(new UserResource())).isFalse(); + + // Create a ListResponse with an unequal superclass value, even though list + // responses do not have an "id". + ListResponse unequalSuperclass = + new ListResponse<>(List.of(new UserResource())); + unequalSuperclass.setId("undefined"); + assertThat(list.equals(unequalSuperclass)).isFalse(); + + var totalResultsValue = new ListResponse<>( + 1000, null, null, null, null, List.of(new UserResource())); + assertThat(list.equals(totalResultsValue)).isFalse(); + assertThat(list.hashCode()).isNotEqualTo(totalResultsValue.hashCode()); + var startIndexValue = new ListResponse<>( + 1, 1, null, null, null, List.of(new UserResource())); + assertThat(list.equals(startIndexValue)).isFalse(); + assertThat(list.hashCode()).isNotEqualTo(startIndexValue.hashCode()); + var itemsPerPageValue = new ListResponse<>( + 1, null, 1, null, null, List.of(new UserResource())); + assertThat(list.equals(itemsPerPageValue)).isFalse(); + assertThat(list.hashCode()).isNotEqualTo(itemsPerPageValue.hashCode()); + var prevCursorValue = new ListResponse<>( + 1, null, null, "prev", null, List.of(new UserResource())); + assertThat(list.equals(prevCursorValue)).isFalse(); + assertThat(list.hashCode()).isNotEqualTo(prevCursorValue.hashCode()); + var nextCursorValue = new ListResponse<>( + 1, null, null, null, "next", List.of(new UserResource())); + assertThat(list.equals(nextCursorValue)).isFalse(); + assertThat(list.hashCode()).isNotEqualTo(nextCursorValue.hashCode()); + var resourcesValue = new ListResponse<>( + 1, null, null, null, null, List.of(new UserResource().setTitle("U."))); + assertThat(list.equals(resourcesValue)).isFalse(); + assertThat(list.hashCode()).isNotEqualTo(resourcesValue.hashCode()); + } } diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/SearchRequestTestCase.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/SearchRequestTestCase.java index 2aebfad8..c8a4e435 100644 --- a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/SearchRequestTestCase.java +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/SearchRequestTestCase.java @@ -32,15 +32,16 @@ package com.unboundid.scim2.common; -import com.unboundid.scim2.common.exceptions.ScimException; +import com.unboundid.scim2.common.filters.Filter; import com.unboundid.scim2.common.messages.SearchRequest; import com.unboundid.scim2.common.messages.SortOrder; +import com.unboundid.scim2.common.types.UserResource; import com.unboundid.scim2.common.utils.JsonUtils; -import com.unboundid.scim2.common.utils.StaticUtils; import org.testng.annotations.Test; -import static org.testng.Assert.assertEquals; -import static org.testng.Assert.assertNull; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; /** * Test for search requests. @@ -48,67 +49,142 @@ public class SearchRequestTestCase { /** - * Test search request. - * - * @throws java.io.IOException If an error occurs. - * @throws ScimException If an error occurs. + * Verifies that serialization and deserialization of search request objects + * result in an expected structure. */ @Test - public void testSearchRequest() throws Exception + public void testSerialization() throws Exception { - // Test case insensitivity - SearchRequest searchRequest = JsonUtils.getObjectReader(). - forType(SearchRequest.class). - readValue(""" - { - "schemas": [ - "urn:ietf:params:scim:api:messages:2.0:SearchRequest" - ], - "attributes": ["displayName", "userName"], - "Filter": - "displayName sw \\"smith\\"", - "startIndex": 1, - "counT": 10 - }"""); + // This JSON comes from RFC 7644. + String json = """ + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:SearchRequest" ], + "attributes": [ "displayName", "userName" ], + "filter": "displayName sw \\"smith\\"", + "startIndex": 1, + "count": 10 + }"""; + SearchRequest request = JsonUtils.getObjectReader() + .forType(SearchRequest.class).readValue(json); + + assertThat(request.getSchemaUrns()).containsOnly( + "urn:ietf:params:scim:api:messages:2.0:SearchRequest"); + assertThat(request.getAttributes()).containsOnly("displayName", "userName"); + assertThat(request.getFilter()) + .isEqualTo(Filter.sw("displayName", "smith").toString()); + assertThat(request.getStartIndex()).isEqualTo(1); + assertThat(request.getCount()).isEqualTo(10); + assertThat(request.getExcludedAttributes()).isNull(); + assertThat(request.getSortBy()).isNull(); + assertThat(request.getSortOrder()).isNull(); + assertThat(request.getCursor()).isNull(); + + // Reformat the JSON into a standardized form. When the Java object is + // serialized into a string, it should match the exact structure. + final String expectedJSON = JsonUtils.getObjectReader() + .readTree(json).toString(); + String serialized = JsonUtils.getObjectWriter().writeValueAsString(request); + assertThat(serialized).isEqualTo(expectedJSON); + + // The same JSON with different casing for attribute names should be + // considered equivalent. + SearchRequest requestWithCasing = JsonUtils.getObjectReader() + .forType(SearchRequest.class).readValue(""" + { + "SCHEMAS": [ "urn:ietf:params:scim:api:messages:2.0:SearchRequest" ], + "attRibutes": [ "displayName", "userName" ], + "Filter": "displayName sw \\"smith\\"", + "startIndex": 1, + "counT": 10 + }"""); + assertThat(request).isEqualTo(requestWithCasing); + + // Use another JSON object with cursor values described from RFC 9865. + String cursorJSON = """ + { + "schemas": [ "urn:ietf:params:scim:api:messages:2.0:SearchRequest" ], + "attributes": [ "id", "addresses", "active" ], + "excludedAttributes": [ "meta" ], + "filter": "displayName sw \\"smith\\"", + "sortBy": "id", + "sortOrder": "descending", + "cursor": "YkU3OF86Pz0rGv", + "count": 5 + }"""; + SearchRequest cursorRequest = JsonUtils.getObjectReader() + .forType(SearchRequest.class).readValue(cursorJSON); - assertEquals(searchRequest.getAttributes(), - StaticUtils.arrayToSet("displayName", "userName")); - assertNull(searchRequest.getExcludedAttributes()); - assertEquals(searchRequest.getFilter(), "displayName sw \"smith\""); - assertNull(searchRequest.getSortBy()); - assertNull(searchRequest.getSortOrder()); - assertEquals(searchRequest.getStartIndex(), Integer.valueOf(1)); - assertEquals(searchRequest.getCount(), Integer.valueOf(10)); + assertThat(cursorRequest.getSchemaUrns()).containsOnly( + "urn:ietf:params:scim:api:messages:2.0:SearchRequest"); + assertThat(cursorRequest.getAttributes()) + .containsOnly("id", "addresses", "active"); + assertThat(cursorRequest.getExcludedAttributes()).containsOnly("meta"); + assertThat(cursorRequest.getFilter()) + .isEqualTo(Filter.sw("displayName", "smith").toString()); + assertThat(cursorRequest.getSortBy()).isEqualTo("id"); + assertThat(cursorRequest.getSortOrder()).isEqualTo(SortOrder.DESCENDING); + assertThat(cursorRequest.getStartIndex()).isNull(); + assertThat(cursorRequest.getCursor()).isEqualTo("YkU3OF86Pz0rGv"); + assertThat(cursorRequest.getCount()).isEqualTo(5); - searchRequest = - JsonUtils.getObjectReader().forType(SearchRequest.class).readValue(""" - { - "schemas": [ - "urn:ietf:params:scim:api:messages:2.0:SearchRequest" - ], - "excludedAttributes": ["displayName", "userName"], - "sortBy": "name.lastName", - "sortOrder": "descending" - }"""); + // Ensure that serializing the object results in the expected JSON string. + final String expectedCursorJSON = JsonUtils.getObjectReader() + .readTree(json).toString(); + String s = JsonUtils.getObjectWriter().writeValueAsString(request); + assertThat(s).isEqualTo(expectedCursorJSON); + } - assertNull(searchRequest.getAttributes()); - assertEquals(searchRequest.getExcludedAttributes(), - StaticUtils.arrayToSet("displayName", "userName")); - assertNull(searchRequest.getFilter()); - assertEquals(searchRequest.getSortBy(), "name.lastName"); - assertEquals(searchRequest.getSortOrder(), SortOrder.DESCENDING); - assertNull(searchRequest.getStartIndex()); - assertNull(searchRequest.getCount()); + /** + * Tests for {@code equals()}. + */ + @SuppressWarnings("all") + @Test + public void testEquals() throws Exception + { + final String filter = Filter.eq("userName", "alice").toString(); + SearchRequest request = new SearchRequest( + null, null, filter, null, null, null, null, 10); + assertThat(request).isEqualTo(request); + assertThat(request).isNotEqualTo(null); + assertThat(request).isNotEqualTo(new UserResource()); - searchRequest = new SearchRequest( - StaticUtils.arrayToSet("displayName", "userName"), - StaticUtils.arrayToSet("addresses"), - "userName eq \"test\"", "name.lastName", - SortOrder.ASCENDING, 5, 100); + SearchRequest unequalSuperclass = new SearchRequest( + null, null, filter, null, null, null, null, 10); + unequalSuperclass.setId("undefined"); + assertThat(request).isNotEqualTo(unequalSuperclass); - String serialized = JsonUtils.getObjectWriter(). - writeValueAsString(searchRequest); - assertEquals(JsonUtils.getObjectReader().forType(SearchRequest.class). - readValue(serialized), searchRequest); + var attributesValue = new SearchRequest( + Set.of("title"), null, filter, null, null, null, null, 10); + assertThat(request).isNotEqualTo(attributesValue); + assertThat(request.hashCode()).isNotEqualTo(attributesValue.hashCode()); + var excludedAttributesValue = new SearchRequest( + null, Set.of("meta"), filter, null, null, null, null, 10); + assertThat(request).isNotEqualTo(excludedAttributesValue); + assertThat(request.hashCode()).isNotEqualTo(excludedAttributesValue.hashCode()); + var filterValue = new SearchRequest( + null, null, Filter.ne("userName", "alice").toString(), + null, null, null, null, 10); + assertThat(request).isNotEqualTo(filterValue); + assertThat(request.hashCode()).isNotEqualTo(filterValue.hashCode()); + var sortByValue = new SearchRequest( + null, null, filter, "userName", null, null, null, 10); + assertThat(request).isNotEqualTo(sortByValue); + assertThat(request.hashCode()).isNotEqualTo(sortByValue.hashCode()); + var sortOrderValue = new SearchRequest( + null, null, filter, null, SortOrder.ASCENDING, null, null, 10); + assertThat(request).isNotEqualTo(sortOrderValue); + assertThat(request.hashCode()).isNotEqualTo(sortOrderValue.hashCode()); + var startIndexValue = new SearchRequest( + null, null, filter, null, null, 1, null, 10); + assertThat(request).isNotEqualTo(startIndexValue); + assertThat(request.hashCode()).isNotEqualTo(startIndexValue.hashCode()); + var cursorValue = new SearchRequest( + null, null, filter, null, null, null, "nextValue", 10); + assertThat(request).isNotEqualTo(cursorValue); + assertThat(request.hashCode()).isNotEqualTo(cursorValue.hashCode()); + var countValue = new SearchRequest( + null, null, filter, null, null, null, null, 10_000); + assertThat(request).isNotEqualTo(countValue); + assertThat(request.hashCode()).isNotEqualTo(countValue.hashCode()); } } 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 79895ae2..d2571727 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 @@ -167,6 +167,12 @@ public void testBadRequestException() assertThat(e.getScimError().getScimType()).isEqualTo("invalidValue"); e = BadRequestException.invalidVersion("Message"); assertThat(e.getScimError().getScimType()).isEqualTo("invalidVersion"); + e = BadRequestException.invalidCursor("Message"); + assertThat(e.getScimError().getScimType()).isEqualTo("invalidCursor"); + e = BadRequestException.expiredCursor("Message"); + assertThat(e.getScimError().getScimType()).isEqualTo("expiredCursor"); + e = BadRequestException.invalidCount("Message"); + assertThat(e.getScimError().getScimType()).isEqualTo("invalidCount"); } /** diff --git a/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/ServiceProviderConfigTest.java b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/ServiceProviderConfigTest.java new file mode 100644 index 00000000..1fb55f6a --- /dev/null +++ b/scim2-sdk-common/src/test/java/com/unboundid/scim2/common/types/ServiceProviderConfigTest.java @@ -0,0 +1,305 @@ +/* + * Copyright 2025 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 2025 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.types; + +import com.unboundid.scim2.common.utils.JsonUtils; +import com.unboundid.scim2.common.utils.SchemaUtils; +import org.testng.annotations.Test; + +import java.util.Collection; + +import static org.assertj.core.api.Assertions.assertThat; + + +/** + * Tests for {@link ServiceProviderConfigResource}. + */ +public class ServiceProviderConfigTest +{ + private final ServiceProviderConfigResource serviceProviderConfig = + new ServiceProviderConfigResource( + "https://example.com/doc", + new PatchConfig(true), + new BulkConfig(true, 100, 1000), + new FilterConfig(true, 200), + new ChangePasswordConfig(true), + new SortConfig(true), + new ETagConfig(false), + new PaginationConfig(true, false, "cursor", 200, 200, null), + new AuthenticationScheme( + "Basic", "HTTP BASIC", null, null, "httpbasic", true)); + + /** + * Basic validation for service provider configuration resources. + */ + @Test + public void testBasic() + { + assertThat(serviceProviderConfig.getDocumentationUri()) + .isEqualTo("https://example.com/doc"); + assertThat(serviceProviderConfig.getPatch().isSupported()).isTrue(); + + BulkConfig bulkConfig = serviceProviderConfig.getBulk(); + assertThat(bulkConfig.isSupported()).isTrue(); + assertThat(bulkConfig.getMaxOperations()).isEqualTo(100); + assertThat(bulkConfig.getMaxPayloadSize()).isEqualTo(1000); + + FilterConfig filterConfig = serviceProviderConfig.getFilter(); + assertThat(filterConfig.isSupported()).isTrue(); + assertThat(filterConfig.getMaxResults()).isEqualTo(200); + + assertThat(serviceProviderConfig.getChangePassword().isSupported()) + .isTrue(); + assertThat(serviceProviderConfig.getSort().isSupported()).isTrue(); + assertThat(serviceProviderConfig.getEtag().isSupported()).isFalse(); + + PaginationConfig paginationConfig = serviceProviderConfig.getPagination(); + assertThat(paginationConfig).isNotNull(); + assertThat(paginationConfig.supportsCursorPagination()).isTrue(); + assertThat(paginationConfig.supportsIndexPagination()).isFalse(); + assertThat(paginationConfig.getDefaultPaginationMethod()) + .isEqualTo("cursor"); + assertThat(paginationConfig.getDefaultPageSize()).isEqualTo(200); + assertThat(paginationConfig.getMaxPageSize()).isEqualTo(200); + assertThat(paginationConfig.getCursorTimeout()).isNull(); + + assertThat(serviceProviderConfig.getAuthenticationSchemes()).hasSize(1); + AuthenticationScheme authConfig = + serviceProviderConfig.getAuthenticationSchemes().get(0); + assertThat(authConfig.getName()).isEqualTo("Basic"); + assertThat(authConfig.getDescription()).isEqualTo("HTTP BASIC"); + assertThat(authConfig.getSpecUri()).isNull(); + assertThat(authConfig.getDocumentationUri()).isNull(); + assertThat(authConfig.getType()).isEqualTo("httpbasic"); + assertThat(authConfig.isPrimary()).isTrue(); + } + + /** + * Ensures that serialization and deserialization attain the expected forms. + */ + @Test + public void testSerialization() throws Exception + { + // The expected JSON for the "serviceProviderConfig" test variable. + String json = """ + { + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig" + ], + "documentationUri": "https://example.com/doc", + "patch": { + "supported": true + }, + "bulk": { + "supported": true, + "maxOperations": 100, + "maxPayloadSize": 1000 + }, + "filter": { + "supported": true, + "maxResults": 200 + }, + "changePassword": { + "supported": true + }, + "sort": { + "supported": true + }, + "etag": { + "supported": false + }, + "pagination": { + "cursor": true, + "index": false, + "defaultPaginationMethod": "cursor", + "defaultPageSize": 200, + "maxPageSize": 200 + }, + "authenticationSchemes": [ { + "name": "Basic", + "description": "HTTP BASIC", + "type": "httpbasic", + "primary": true + } ] + }"""; + + // Reformat the JSON into a standardized form, and ensure the serialized + // object matches the result. + final String expectedJSON = JsonUtils.getObjectReader() + .readTree(json).toString(); + String serialized = JsonUtils.getObjectWriter() + .writeValueAsString(serviceProviderConfig); + assertThat(serialized).isEqualTo(expectedJSON); + + // Ensure the deserialized value is considered equivalent to the original. + ServiceProviderConfigResource deserialized = JsonUtils.getObjectReader() + .forType(ServiceProviderConfigResource.class).readValue(json); + assertThat(deserialized).isEqualTo(serviceProviderConfig); + assertThat(deserialized.hashCode()) + .isEqualTo(serviceProviderConfig.hashCode()); + } + + /** + * Ensures that all attributes defined on the ServiceProviderConfig resource + * have a mutability of "read-only". + */ + @Test + public void testAttributesReadOnly() throws Exception + { + Collection spcSchema = + SchemaUtils.getAttributes(ServiceProviderConfigResource.class); + assertThat(spcSchema).allMatch(attr -> + attr.getMutability() == AttributeDefinition.Mutability.READ_ONLY); + } + + /** + * Tests for {@code equals()}. + */ + @SuppressWarnings("all") + @Test + public void testEquals() + { + assertThat(serviceProviderConfig).isEqualTo(serviceProviderConfig); + assertThat(serviceProviderConfig).isNotEqualTo(null); + assertThat(serviceProviderConfig).isNotEqualTo(new UserResource()); + + var docValue = new ServiceProviderConfigResource( + "https://example.com/newURL.txt", + new PatchConfig(false), + new BulkConfig(true, 100, 1000), + new FilterConfig(true, 200), + new ChangePasswordConfig(true), + new SortConfig(true), + new ETagConfig(false), + new PaginationConfig(true, false, "cursor", 200, 200, null), + new AuthenticationScheme( + "Basic", "HTTP BASIC", null, null, "httpbasic", true)); + assertThat(serviceProviderConfig).isNotEqualTo(docValue); + var patchValue = new ServiceProviderConfigResource( + "https://example.com/doc", + new PatchConfig(false), + new BulkConfig(true, 100, 1000), + new FilterConfig(true, 200), + new ChangePasswordConfig(true), + new SortConfig(true), + new ETagConfig(false), + new PaginationConfig(true, false, "cursor", 200, 200, null), + new AuthenticationScheme( + "Basic", "HTTP BASIC", null, null, "httpbasic", true)); + assertThat(serviceProviderConfig).isNotEqualTo(patchValue); + var bulkValue = new ServiceProviderConfigResource( + "https://example.com/doc", + new PatchConfig(true), + new BulkConfig(false, 0, 0), + new FilterConfig(true, 200), + new ChangePasswordConfig(true), + new SortConfig(true), + new ETagConfig(false), + new PaginationConfig(true, false, "cursor", 200, 200, null), + new AuthenticationScheme( + "Basic", "HTTP BASIC", null, null, "httpbasic", true)); + assertThat(serviceProviderConfig).isNotEqualTo(bulkValue); + var filterValue = new ServiceProviderConfigResource( + "https://example.com/doc", + new PatchConfig(true), + new BulkConfig(true, 100, 1000), + new FilterConfig(false, 0), + new ChangePasswordConfig(true), + new SortConfig(true), + new ETagConfig(false), + new PaginationConfig(true, false, "cursor", 200, 200, null), + new AuthenticationScheme( + "Basic", "HTTP BASIC", null, null, "httpbasic", true)); + assertThat(serviceProviderConfig).isNotEqualTo(filterValue); + var changePasswordValue = new ServiceProviderConfigResource( + "https://example.com/doc", + new PatchConfig(true), + new BulkConfig(true, 100, 1000), + new FilterConfig(true, 200), + new ChangePasswordConfig(false), + new SortConfig(true), + new ETagConfig(false), + new PaginationConfig(true, false, "cursor", 200, 200, null), + new AuthenticationScheme( + "Basic", "HTTP BASIC", null, null, "httpbasic", true)); + assertThat(serviceProviderConfig).isNotEqualTo(changePasswordValue); + var sortValue = new ServiceProviderConfigResource( + "https://example.com/doc", + new PatchConfig(true), + new BulkConfig(true, 100, 1000), + new FilterConfig(true, 200), + new ChangePasswordConfig(true), + new SortConfig(false), + new ETagConfig(false), + new PaginationConfig(true, false, "cursor", 200, 200, null), + new AuthenticationScheme( + "Basic", "HTTP BASIC", null, null, "httpbasic", true)); + assertThat(serviceProviderConfig).isNotEqualTo(sortValue); + var etagValue = new ServiceProviderConfigResource( + "https://example.com/doc", + new PatchConfig(true), + new BulkConfig(true, 100, 1000), + new FilterConfig(true, 200), + new ChangePasswordConfig(true), + new SortConfig(true), + new ETagConfig(true), + new PaginationConfig(true, false, "cursor", 200, 200, null), + new AuthenticationScheme( + "Basic", "HTTP BASIC", null, null, "httpbasic", true)); + assertThat(serviceProviderConfig).isNotEqualTo(etagValue); + var paginationValue = new ServiceProviderConfigResource( + "https://example.com/doc", + new PatchConfig(true), + new BulkConfig(true, 100, 1000), + new FilterConfig(true, 200), + new ChangePasswordConfig(true), + new SortConfig(true), + new ETagConfig(false), + null, + new AuthenticationScheme( + "Basic", "HTTP BASIC", null, null, "httpbasic", true)); + assertThat(serviceProviderConfig).isNotEqualTo(paginationValue); + var authValue = new ServiceProviderConfigResource( + "https://example.com/doc", + new PatchConfig(true), + new BulkConfig(true, 100, 1000), + new FilterConfig(true, 200), + new ChangePasswordConfig(true), + new SortConfig(true), + new ETagConfig(false), + new PaginationConfig(true, false, "cursor", 200, 200, null), + new AuthenticationScheme( + "OAuth 2.0", "OAuth 2.0", null, null, "OAuth 2.0", true)); + assertThat(serviceProviderConfig).isNotEqualTo(authValue); + } +} diff --git a/scim2-sdk-server/src/main/java/com/unboundid/scim2/server/ListResponseWriter.java b/scim2-sdk-server/src/main/java/com/unboundid/scim2/server/ListResponseWriter.java index b917b543..86495bb8 100644 --- a/scim2-sdk-server/src/main/java/com/unboundid/scim2/server/ListResponseWriter.java +++ b/scim2-sdk-server/src/main/java/com/unboundid/scim2/server/ListResponseWriter.java @@ -146,6 +146,26 @@ public void startIndex(final int startIndex) throws IOException } } + /** + * Write the nextCursor to the output stream immediately if no resources have + * been streamed, otherwise it will be written after the resources array. + * + * @param nextCursor The nextCursor to write. + * @throws IOException If an exception occurs while writing to the output + * stream. + */ + public void nextCursor(@NotNull final String nextCursor) throws IOException + { + if (startedResourcesArray.get()) + { + deferredFields.put("nextCursor", nextCursor); + } + else + { + jsonGenerator.writeStringField("nextCursor", nextCursor); + } + } + /** * Write the itemsPerPage to the output stream immediately if no resources * have been streamed, otherwise it will be written after the resources array. diff --git a/scim2-sdk-server/src/main/java/com/unboundid/scim2/server/utils/SimpleSearchResults.java b/scim2-sdk-server/src/main/java/com/unboundid/scim2/server/utils/SimpleSearchResults.java index 818f529b..6e0ff626 100644 --- a/scim2-sdk-server/src/main/java/com/unboundid/scim2/server/utils/SimpleSearchResults.java +++ b/scim2-sdk-server/src/main/java/com/unboundid/scim2/server/utils/SimpleSearchResults.java @@ -65,12 +65,18 @@ public class SimpleSearchResults @NotNull private final List resources; - @NotNull + @Nullable private final Filter filter; @Nullable private final Integer startIndex; + // Stores a "cursor" value for cursor-based pagination. For simplicity, this + // class sets cursor values to string integers, which should not be used for + // production use cases. + @Nullable + private final String nextCursor; + @Nullable private final Integer count; @@ -103,9 +109,10 @@ public SimpleSearchResults(@NotNull final ResourceTypeDefinition resourceType, String filterString = queryParams.getFirst(QUERY_PARAMETER_FILTER); String startIndexString = queryParams.getFirst( QUERY_PARAMETER_PAGE_START_INDEX); + String nextCursorString = queryParams.getFirst(QUERY_PARAMETER_PAGE_CURSOR); String countString = queryParams.getFirst(QUERY_PARAMETER_PAGE_SIZE); String sortByString = queryParams.getFirst(QUERY_PARAMETER_SORT_BY); - String sortOrderString = queryParams.getFirst(QUERY_PARAMETER_SORT_ORDER); + String sortOrderString = queryParams.getFirst(QUERY_PARAMETER_SORT_ORDER); if (filterString != null) { @@ -116,15 +123,43 @@ public SimpleSearchResults(@NotNull final ResourceTypeDefinition resourceType, this.filter = null; } + if (startIndexString != null && nextCursorString != null) + { + throw BadRequestException.invalidCursor( + "SimpleSearchResults does not allow index and cursor" + + " pagination in the same request."); + } if (startIndexString != null) { // RFC 7644 3.4.2.4: A value less than 1 SHALL be interpreted as 1. int i = Integer.parseInt(startIndexString); startIndex = Math.max(i, 1); + nextCursor = null; + } + else if (queryParams.containsKey("cursor")) + { + // SimpleSearchResults uses a page identifier of a numerical string, for + // simplicity and for parity with index-based pagination behavior. + if (nextCursorString == null || nextCursorString.isEmpty()) + { + // This is requesting the first page of results. Return the ID for the + // next page. + nextCursor = "2"; + } + else + { + // This is requesting a specific page with a query like "?cursor=VZUTi". + // For SimpleSearchResults, this will be of the form "?cursor=3". + int i = Integer.parseInt(nextCursorString) + 1; + nextCursor = String.valueOf(Math.max(i, 2)); + } + + startIndex = null; } else { startIndex = null; + nextCursor = null; } if (countString != null) @@ -229,18 +264,9 @@ public void write(@NotNull final ListResponseWriter os) { resources.sort(resourceComparator); } - List resultsToReturn = resources; - if (startIndex != null) - { - if (startIndex > resources.size()) - { - resultsToReturn = Collections.emptyList(); - } - else - { - resultsToReturn = resources.subList(startIndex - 1, resources.size()); - } - } + + List resultsToReturn = handlePaging(resources); + if (count != null && !resultsToReturn.isEmpty()) { resultsToReturn = resultsToReturn.subList( @@ -249,12 +275,59 @@ public void write(@NotNull final ListResponseWriter os) os.totalResults(resources.size()); if (startIndex != null || count != null) { - os.startIndex(startIndex == null ? 1 : startIndex); os.itemsPerPage(resultsToReturn.size()); } + if (startIndex != null) + { + os.startIndex(startIndex); + } + if (nextCursor != null + && !resultsToReturn.isEmpty() + && !resultsToReturn.get(resultsToReturn.size() - 1).equals( + resources.get(resources.size() - 1))) + { + // Add the nextCursor value when cursor-based pagination is used, and the + // last result has not yet been returned. + os.nextCursor(nextCursor); + } for (ScimResource resource : resultsToReturn) { os.resource((T) responsePreparer.trimRetrievedResource(resource)); } } + + /** + * Returns a set of resources based on the current paging constraints. + * + * @param resources The full result set. + * @return The results that should be returned. + */ + @NotNull + private List handlePaging( + @NotNull final List resources) + { + if (startIndex == null && nextCursor == null) + { + return resources; + } + + int index; + if (startIndex != null) + { + index = startIndex; + } + else + { + index = Integer.parseInt(nextCursor) - 1; + } + + if (index > resources.size()) + { + return Collections.emptyList(); + } + else + { + return resources.subList(index - 1, resources.size()); + } + } } diff --git a/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/EndpointTestCase.java b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/EndpointTestCase.java index 68fd158d..ff58918d 100644 --- a/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/EndpointTestCase.java +++ b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/EndpointTestCase.java @@ -377,7 +377,8 @@ public void testGetResourceType() throws ScimException } /** - * Test an resource endpoint implementation registered as a class. + * Test a call to a {@code /Users} endpoint with query parameters. The + * response is defined in {@link TestResourceEndpoint#searchFourResults}. * * @throws ScimException if an error occurs. */ @@ -385,24 +386,100 @@ public void testGetResourceType() throws ScimException public void testGetUsers() throws ScimException { final ScimService service = new ScimService(target()); - final ListResponse returnedUsers = - service.searchRequest("Users"). - filter("meta.resourceType eq \"User\""). - page(1, 10). - sort("id", SortOrder.ASCENDING). - attributes("id", "name", "Meta"). - invoke(UserResource.class); - assertEquals(returnedUsers.getTotalResults(), 1); - assertEquals(returnedUsers.getStartIndex(), Integer.valueOf(1)); - assertEquals(returnedUsers.getItemsPerPage(), Integer.valueOf(1)); + // First send a request to /Users with cursor-based pagination, requesting + // two resources and a subset of attributes. + final ListResponse returnedUsers = + service.searchRequest("/Users/WithFourResults") + .filter(Filter.eq("meta.resourceType", "User")) + .page(1, 2) + .sort("id", SortOrder.ASCENDING) + .attributes("id", "name", "Meta") + .invoke(UserResource.class); - final UserResource r = returnedUsers.getResources().get(0); - service.retrieve(r); + // There are a total of four users, but two should have been returned. + assertThat(returnedUsers.getTotalResults()).isEqualTo(4); + assertThat(returnedUsers.getItemsPerPage()).isEqualTo(2); + + // The response should indicate this is the first page. + assertThat(returnedUsers.getStartIndex()).isEqualTo(1); + + // The users should have been sorted by ID. + assertThat(returnedUsers.getResources().get(0).getId()) + .isEqualTo("286080c5"); + assertThat(returnedUsers.getResources().get(1).getId()) + .isEqualTo("69d17c9d"); + + // The userName attribute was not requested, so this should be null in the + // result even though it was present on the user. + assertThat(returnedUsers).allMatch(user -> user.getUserName() == null); + + // Fetch a subset of responses and request a cursor. The SimpleSearchResults + // implementation should return a string "2" cursor. This time, the + // usernames should be provided since most attributes will be returned. + ListResponse returnedUsersCursor = + service.searchRequest("/Users/WithFourResults") + .firstPageCursorWithCount(2) + .invoke(UserResource.class); + assertThat(returnedUsersCursor.getTotalResults()).isEqualTo(4); + assertThat(returnedUsersCursor.getItemsPerPage()).isEqualTo(2); + assertThat(returnedUsersCursor.getNextCursor()).isEqualTo("2"); + assertThat(returnedUsersCursor.getStartIndex()).isNull(); + assertThat(returnedUsersCursor.getResources()).hasSize(2); + assertThat(returnedUsersCursor).allMatch(u -> u.getUserName() != null); + + // Ensure it is possible to use the returned cursor to fetch the next page. + returnedUsersCursor = service.searchRequest("/Users/WithFourResults") + .pageWithCursor("2", 2) + .invoke(UserResource.class); + assertThat(returnedUsersCursor.getTotalResults()).isEqualTo(4); + assertThat(returnedUsersCursor.getItemsPerPage()).isEqualTo(2); + assertThat(returnedUsersCursor.getNextCursor()).isEqualTo("3"); + assertThat(returnedUsersCursor.getStartIndex()).isNull(); + assertThat(returnedUsersCursor.getResources()).hasSize(2); + + // Request cursor pagination, but fetch all resources. A value should not be + // returned for "nextCursor" since there are no more results to display. + ListResponse allUsersWithCursor = + service.searchRequest("/Users/WithFourResults") + .firstPageCursorWithCount(100) + .invoke(UserResource.class); + assertThat(allUsersWithCursor.getTotalResults()).isEqualTo(4); + assertThat(allUsersWithCursor.getItemsPerPage()).isEqualTo(4); + assertThat(allUsersWithCursor.getNextCursor()).isNull(); + assertThat(allUsersWithCursor.getStartIndex()).isNull(); + assertThat(allUsersWithCursor.getResources()).hasSize(4); + + // Fetch all remaining resources starting at the second page. Again, a value + // should not be returned for "nextCursor". + ListResponse remainingUsersWithCursor = + service.searchRequest("/Users/WithFourResults") + .pageWithCursor("2", 100) + .invoke(UserResource.class); + assertThat(remainingUsersWithCursor.getTotalResults()).isEqualTo(4); + assertThat(remainingUsersWithCursor.getItemsPerPage()).isEqualTo(3); + assertThat(remainingUsersWithCursor.getNextCursor()).isNull(); + assertThat(remainingUsersWithCursor.getStartIndex()).isNull(); + assertThat(remainingUsersWithCursor.getResources()).hasSize(3); + + // Search for results with a filter that matches no resources. Request a + // cursor, though a "nextCursor" value should not be returned since there + // are no more results. + ListResponse emptyLResults = + service.searchRequest("/Users/WithFourResults") + .filter(Filter.pr("x509Certificates")) + .firstPageCursorWithCount(1) + .invoke(UserResource.class); + assertThat(emptyLResults.getTotalResults()).isEqualTo(0); + assertThat(emptyLResults.getItemsPerPage()).isEqualTo(0); + assertThat(emptyLResults.getNextCursor()).isNull(); + assertThat(emptyLResults.getStartIndex()).isNull(); + assertThat(emptyLResults.getResources()).hasSize(0); } /** - * Test an resource endpoint implementation registered as a class. + * Test a resource endpoint implementation registered as a class. The response + * is defined in {@link TestResourceEndpoint#searchOneResult}. * * @throws ScimException if an error occurs. */ @@ -1170,13 +1247,12 @@ public void testListResponseParsingCaseSensitivity() throws Exception // have been successfully parsed. assertThat(response.getSchemaUrns()) .hasSize(1) - .first() - .isEqualTo("urn:ietf:params:scim:api:messages:2.0:ListResponse"); + .containsOnly("urn:ietf:params:scim:api:messages:2.0:ListResponse"); assertThat(response.getTotalResults()).isEqualTo(2); assertThat(response.getItemsPerPage()).isEqualTo(1); assertThat(response.getResources()) .hasSize(1) - .first().isEqualTo(new UserResource().setUserName("k.dot")); + .containsOnly(new UserResource().setUserName("k.dot")); // startIndex was not included, so it should not have a value. assertThat(response.getStartIndex()).isNull(); @@ -1199,19 +1275,45 @@ public void testLastFieldUnknown() throws Exception assertThat(response.getSchemaUrns()) .hasSize(1) - .first() - .isEqualTo("urn:ietf:params:scim:api:messages:2.0:ListResponse"); + .containsOnly("urn:ietf:params:scim:api:messages:2.0:ListResponse"); assertThat(response.getTotalResults()).isEqualTo(1); assertThat(response.getItemsPerPage()).isEqualTo(1); assertThat(response.getResources()) .hasSize(1) - .first().isEqualTo(new UserResource().setUserName("GNX")); + .containsOnly(new UserResource().setUserName("GNX")); assertThat(response.toString()) .doesNotContain("unknownAttribute") .doesNotContain("unknownValue"); } + /** + * Test a response that includes {@code previousCursor}. The list response + * returned by the service is defined in + * {@link TestResourceEndpoint#testCursorPagination}. + */ + @Test + public void testCursorPagination() throws Exception + { + final ScimService service = new ScimService(target()); + ListResponse response = + service.searchRequest("/Users/testCursorPagination") + .invoke(UserResource.class); + + assertThat(response.getSchemaUrns()) + .hasSize(1) + .containsOnly("urn:ietf:params:scim:api:messages:2.0:ListResponse"); + assertThat(response.getTotalResults()).isEqualTo(20); + assertThat(response.getItemsPerPage()).isEqualTo(1); + assertThat(response.getStartIndex()).isNull(); + assertThat(response.getPreviousCursor()).isEqualTo("ze7L30kMiiLX6x"); + assertThat(response.getNextCursor()).isEqualTo("YkU3OF86Pz0rGv"); + assertThat(response.getResources()) + .hasSize(1) + .containsOnly(new UserResource().setUserName("reincarnated")); + } + + /** * Test custom content-type. * diff --git a/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/TestResourceEndpoint.java b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/TestResourceEndpoint.java index 01e87e73..548a2bf7 100644 --- a/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/TestResourceEndpoint.java +++ b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/TestResourceEndpoint.java @@ -51,6 +51,8 @@ import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.UriInfo; +import java.util.Objects; + import static com.unboundid.scim2.common.utils.ApiConstants.MEDIA_TYPE_SCIM; /** @@ -67,8 +69,8 @@ public class TestResourceEndpoint { private static final ResourceTypeDefinition RESOURCE_TYPE_DEFINITION = - ResourceTypeDefinition.fromJaxRsResource( - TestResourceEndpoint.class); + Objects.requireNonNull( + ResourceTypeDefinition.fromJaxRsResource(TestResourceEndpoint.class)); /** * This method will simply return a poorly formated SCIM exception and @@ -125,15 +127,34 @@ public Response getResponseWithStatusUnauthorizedAndTypeOctetStreamAndBadEntity( */ @GET @Produces({MEDIA_TYPE_SCIM, MediaType.APPLICATION_JSON}) - public SimpleSearchResults search( + public SimpleSearchResults searchOneResult( @Context final UriInfo uriInfo) throws ScimException { - UserResource resource = new UserResource().setUserName("test"); - resource.setId("123"); + SimpleSearchResults results = + new SimpleSearchResults<>(RESOURCE_TYPE_DEFINITION, uriInfo); + results.add(newUserWithId("69d17c9d").setUserName("Frieren")); + + return results; + } + /** + * Equivalent to {@link #searchOneResult}, with additional values returned. + * + * @param uriInfo The UriInfo. + * @return A SimpleSearchResults object with four resources. + */ + @GET + @Path("/WithFourResults") + @Produces({MEDIA_TYPE_SCIM, MediaType.APPLICATION_JSON}) + public SimpleSearchResults searchFourResults( + @Context final UriInfo uriInfo) throws ScimException + { SimpleSearchResults results = new SimpleSearchResults<>(RESOURCE_TYPE_DEFINITION, uriInfo); - results.add(resource); + results.add(newUserWithId("69d17c9d").setUserName("Frieren")); + results.add(newUserWithId("286080c5").setUserName("Fern")); + results.add(newUserWithId("7b847d9f").setUserName("Stark")); + results.add(newUserWithId("bb3c36c2").setUserName("Sein")); return results; } @@ -226,4 +247,42 @@ public Response testLastFieldUnknown() "unknownAttribute": "unknownValue" }""").build(); } + + /** + * Returns a list response with an extra undefined attribute listed as the + * last attribute in the list. + * + * @return A list response. + */ + @GET + @Path("testCursorPagination") + @Produces({MEDIA_TYPE_SCIM, MediaType.APPLICATION_JSON}) + public Response testCursorPagination() + { + return Response.status(Response.Status.OK) + .type(MEDIA_TYPE_SCIM) + .entity(""" + { + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse" + ], + "totalResults": 20, + "itemsPerPage": 1, + "previousCursor": "ze7L30kMiiLX6x", + "nextCursor": "YkU3OF86Pz0rGv", + "Resources": [ + { + "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], + "userName": "reincarnated" + } + ] + }""").build(); + } + + private UserResource newUserWithId(String id) + { + var user = new UserResource(); + user.setId(id); + return user; + } } diff --git a/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/TestServiceProviderConfigEndpoint.java b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/TestServiceProviderConfigEndpoint.java index eb35fdfc..01b6872e 100644 --- a/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/TestServiceProviderConfigEndpoint.java +++ b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/TestServiceProviderConfigEndpoint.java @@ -38,14 +38,12 @@ import com.unboundid.scim2.common.types.ChangePasswordConfig; import com.unboundid.scim2.common.types.ETagConfig; import com.unboundid.scim2.common.types.FilterConfig; +import com.unboundid.scim2.common.types.PaginationConfig; import com.unboundid.scim2.common.types.PatchConfig; import com.unboundid.scim2.common.types.ServiceProviderConfigResource; import com.unboundid.scim2.common.exceptions.ScimException; import com.unboundid.scim2.common.types.SortConfig; -import com.unboundid.scim2.server.resources. - AbstractServiceProviderConfigEndpoint; - -import java.util.Collections; +import com.unboundid.scim2.server.resources.AbstractServiceProviderConfigEndpoint; /** * A test Service Provider Config endpoint implementation that just serves up @@ -72,15 +70,15 @@ public ServiceProviderConfigResource getServiceProviderConfig() */ public static ServiceProviderConfigResource create() { - return new ServiceProviderConfigResource("https://doc", + return new ServiceProviderConfigResource("https://example.com/doc", new PatchConfig(true), new BulkConfig(true, 100, 1000), new FilterConfig(true, 200), new ChangePasswordConfig(true), new SortConfig(true), new ETagConfig(false), - Collections.singletonList( - new AuthenticationScheme( - "Basic", "HTTP BASIC", null, null, "httpbasic", true))); + new PaginationConfig(true, false, "cursor", 200, 200, null), + new AuthenticationScheme( + "Basic", "HTTP BASIC", null, null, "httpbasic", true)); } } diff --git a/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/TestSingletonResourceEndpoint.java b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/TestSingletonResourceEndpoint.java index da6e69c1..dce8fac0 100644 --- a/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/TestSingletonResourceEndpoint.java +++ b/scim2-sdk-server/src/test/java/com/unboundid/scim2/server/TestSingletonResourceEndpoint.java @@ -94,8 +94,7 @@ public SimpleSearchResults search( { SimpleSearchResults results = new SimpleSearchResults<>(RESOURCE_TYPE_DEFINITION, uriInfo); - results.addAll(users.values()); - return results; + return results.addAll(users.values()); } /**