Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -55,6 +56,26 @@ public interface SearchResultHandler<T>
*/
void itemsPerPage(final int itemsPerPage);

/**
* Handle the previousCursor in the search response as defined by
* <a href="https://datatracker.ietf.org/doc/html/rfc9865">RFC 9865</a>.
*
* @param previousCursor The previousCursor.
*
* @since 5.0.0
*/
void previousCursor(@Nullable final String previousCursor);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because of the update to this interface, this technically requires a major version bump if any client code is implementing this handler. Fortunately, the next release is already going to be 5.0.0.


/**
* Handle the nextCursor in the search response as defined by
* <a href="https://datatracker.ietf.org/doc/html/rfc9865">RFC 9865</a>.
*
* @param nextCursor The nextCursor.
*
* @since 5.0.0
*/
void nextCursor(@Nullable final String nextCursor);

/**
* Handle the totalResults in the search response.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ public class ListResponseBuilder<T>
@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}
*/
Expand All @@ -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}
*/
Expand All @@ -96,13 +126,15 @@ public boolean resource(@NotNull final T scimResource)

/**
* {@inheritDoc}
* <p>
* This method currently does not perform any action and should not be used.
* <br><br>
*
* 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.
}

/**
Expand All @@ -115,9 +147,11 @@ public ListResponse<T> build()
{
return new ListResponse<>(
Optional.ofNullable(totalResults).orElse(resources.size()),
resources,
startIndex,
itemsPerPage
itemsPerPage,
previousCursor,
nextCursor,
resources
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -90,6 +92,9 @@ public class SearchRequestBuilder
@Nullable
private Integer startIndex;

@Nullable
private String cursor;

@Nullable
private Integer count;

Expand Down Expand Up @@ -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)
Expand All @@ -148,7 +155,11 @@ public SearchRequestBuilder sort(@Nullable final String sortBy,
}

/**
* Request pagination of resources.
* Request pagination of resources with index-based pagination.
* <br><br>
*
* 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.
Expand All @@ -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}.
* <br><br>
*
* For a cursor value of "VZUTiy", this will be translated to a request like:
* <pre>
* GET /Users?cursor=VZUTiy&amp;count=10
* </pre>
*
* 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:
* <pre>
* GET /Users?cursor&amp;count=10
* </pre>
*
* However, due to the way JAX-RS handles query parameters, this will be
* sent as a key-value pair with an empty value:
* <pre>
* GET /Users?cursor=&amp;count=10
* </pre>
*
* @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)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I included "WithCount" because a call like firstPageCursor(10) seemed very confusing.

{
return pageWithCursor("", count);
}

/**
* {@inheritDoc}
*/
Expand All @@ -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;
}

Expand All @@ -202,7 +291,7 @@ public <T> ListResponse<T> invoke(@NotNull final Class<T> cls)
throws ScimException
{
ListResponseBuilder<T> listResponseBuilder = new ListResponseBuilder<>();
invoke(false, listResponseBuilder, cls);
invoke(listResponseBuilder, cls);
return listResponseBuilder.build();
}

Expand Down Expand Up @@ -236,7 +325,7 @@ public <T extends ScimResource> ListResponse<T> invokePost(
throws ScimException
{
ListResponseBuilder<T> listResponseBuilder = new ListResponseBuilder<>();
invoke(true, listResponseBuilder, cls);
invokePost(listResponseBuilder, cls);
return listResponseBuilder.build();
}

Expand Down Expand Up @@ -303,6 +392,14 @@ private <T> 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;
Expand Down Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
}
Loading
Loading