Skip to content

Commit

Permalink
Update SearchRequestBuilder#invoke processing.
Browse files Browse the repository at this point in the history
This commit updates the SearchRequestBuilder's parsing of ListResponses
returned from SCIM services. Evaluation of expected attributes (e.g.,
"itemsPerPage") are now performed in a case-insensitive manner. This is
intended to broaden compatibility with services that use irregular
casing for attribute names.

This change also includes documentation updates to provide more
information about SearchRequests.

Reviewer: vyhhuang
Reviewer: dougbulkley

JiraIssue: DS-49756
  • Loading branch information
kqarryzada authored Feb 28, 2025
1 parent 14e1197 commit e43e23a
Show file tree
Hide file tree
Showing 8 changed files with 213 additions and 98 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ set the following property in your application:
PatchOperation.APPEND_NEW_PATCH_VALUES_PROPERTY = true;
```

Updated `SearchRequestBuilder` to be more permissive of ListResponses with non-standard attribute
casing (e.g., if a response includes a `"resources"` array instead of `"Resources"`).

Updated the class-level documentation of `SearchRequest` to provide more background about how
searches are performed in the SCIM standard.

## v3.2.0 - 2024-Dec-04
Fixed an issue where `AndFilter.equals()` and `OrFilter.equals()` could incorrectly evaluate to
true.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,12 +267,11 @@ protected List<String> getAccept()
Invocation.Builder buildRequest()
{
Invocation.Builder builder =
buildTarget().request(accept.toArray(new String[accept.size()]));
buildTarget().request(accept.toArray(new String[0]));
for (Map.Entry<String, List<Object>> header : headers.entrySet())
{
builder = builder.header(header.getKey(),
StaticUtils.listToString(header.getValue(),
", "));
String stringValue = StaticUtils.listToString(header.getValue(), ", ");
builder = builder.header(header.getKey(), stringValue);
}
return builder;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public abstract class ResourceReturningRequestBuilder
protected boolean excluded;

/**
* The attribute list of include or exclude.
* The attribute list to include or exclude.
*/
@Nullable
protected Set<String> attributes;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@

package com.unboundid.scim2.client.requests;

import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.unboundid.scim2.client.ScimService;
import com.unboundid.scim2.client.SearchResultHandler;
import com.unboundid.scim2.common.ScimResource;
Expand All @@ -39,7 +39,6 @@
import jakarta.ws.rs.client.Invocation;
import jakarta.ws.rs.client.ResponseProcessingException;
import jakarta.ws.rs.client.WebTarget;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.io.IOException;
import java.io.InputStream;
Expand All @@ -52,9 +51,13 @@
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;
import static com.unboundid.scim2.common.utils.ApiConstants.QUERY_PARAMETER_SORT_ORDER;
import static com.unboundid.scim2.common.utils.StaticUtils.toLowerCase;
import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON_TYPE;
import static jakarta.ws.rs.core.Response.Status.Family.SUCCESSFUL;

/**
* A builder for SCIM search requests.
* This class provides a builder for SCIM 2.0 search requests. For more
* information, see the documentation in {@link SearchRequest}.
*/
public final class SearchRequestBuilder
extends ResourceReturningRequestBuilder<SearchRequestBuilder>
Expand Down Expand Up @@ -235,122 +238,111 @@ public <T> void invokePost(@NotNull final SearchResultHandler<T> resultHandler,
* @throws ProcessingException If a JAX-RS runtime exception occurred.
* @throws ScimException If the SCIM service provider responded with an error.
*/
@SuppressWarnings("SpellCheckingInspection")
private <T> void invoke(final boolean post,
@NotNull final SearchResultHandler<T> resultHandler,
@NotNull final Class<T> cls)
throws ScimException
{
Response response;
if (post)
try (Response response = (post) ? sendPostSearch() : buildRequest().get())
{
Set<String> attributeSet = null;
Set<String> excludedAttributeSet = null;
if (attributes != null && attributes.size() > 0)
if (response.getStatusInfo().getFamily() != SUCCESSFUL)
{
if (!excluded)
{
attributeSet = attributes;
}
else
{
excludedAttributeSet = attributes;
}
throw toScimException(response);
}

SearchRequest searchRequest = new SearchRequest(attributeSet,
excludedAttributeSet, filter, sortBy, sortOrder, startIndex, count);

Invocation.Builder builder = target().
path(ApiConstants.SEARCH_WITH_POST_PATH_EXTENSION).
request(ScimService.MEDIA_TYPE_SCIM_TYPE,
MediaType.APPLICATION_JSON_TYPE);
for (Map.Entry<String, List<Object>> header : headers.entrySet())
final JsonFactory factory = JsonUtils.getObjectReader().getFactory();
try (InputStream inputStream = response.readEntity(InputStream.class);
JsonParser parser = factory.createParser(inputStream))
{
builder = builder.header(header.getKey(),
StaticUtils.listToString(header.getValue(),
", "));
}
response = builder.post(Entity.entity(searchRequest,
getContentType()));
}
else
{
response = buildRequest().get();
}
parser.nextToken();

try
{
if (response.getStatusInfo().getFamily() ==
Response.Status.Family.SUCCESSFUL)
{
InputStream inputStream = response.readEntity(InputStream.class);
try
boolean proceed = true;
while (proceed && parser.nextToken() != JsonToken.END_OBJECT)
{
JsonParser parser = JsonUtils.getObjectReader().
getFactory().createParser(inputStream);
try
String field = String.valueOf(parser.currentName());
parser.nextToken();

switch (toLowerCase(field))
{
parser.nextToken();
boolean stop = false;
while (!stop && parser.nextToken() != JsonToken.END_OBJECT)
{
String field = parser.getCurrentName();
parser.nextToken();
if (field.equals("schemas"))
{
parser.skipChildren();
} else if (field.equals("totalResults"))
{
resultHandler.totalResults(parser.getIntValue());
} else if (field.equals("startIndex"))
{
resultHandler.startIndex(parser.getIntValue());
} else if (field.equals("itemsPerPage"))
case "schemas":
parser.skipChildren();
break;
case "totalresults":
resultHandler.totalResults(parser.getIntValue());
break;
case "startindex":
resultHandler.startIndex(parser.getIntValue());
break;
case "itemsperpage":
resultHandler.itemsPerPage(parser.getIntValue());
break;
case "resources":
while (parser.nextToken() != JsonToken.END_ARRAY)
{
resultHandler.itemsPerPage(parser.getIntValue());
} else if (field.equals("Resources"))
{
while (parser.nextToken() != JsonToken.END_ARRAY)
if (!resultHandler.resource(parser.readValueAs(cls)))
{
if (!resultHandler.resource(parser.readValueAs(cls)))
{
stop = true;
break;
}
proceed = false;
break;
}
} else if (SchemaUtils.isUrn(field))
}
break;

default:
if (SchemaUtils.isUrn(field))
{
resultHandler.extension(
field, parser.<ObjectNode>readValueAsTree());
} else
resultHandler.extension(field, parser.readValueAsTree());
}
else
{
// Just skip this field
parser.nextToken();
}
}
}
finally
{
if (inputStream != null)
{
inputStream.close();
}
parser.close();
}
}
catch (IOException e)
{
throw new ResponseProcessingException(response, e);
}
}
catch (IOException e)
{
throw new ResponseProcessingException(response, e);
}
}
}

/**
* Issues a POST search request, i.e., a {@link SearchRequest}. A common
* example of this is the {@code /Users/.search} endpoint.
*
* @return The HTTP {@link Response} to the POST request.
*/
@NotNull
private Response sendPostSearch()
{
Set<String> attributeSet = null;
Set<String> excludedAttributeSet = null;
if (attributes != null && !attributes.isEmpty())
{
if (excluded)
{
excludedAttributeSet = attributes;
}
else
{
throw toScimException(response);
attributeSet = attributes;
}
}
finally

SearchRequest searchRequest = new SearchRequest(attributeSet,
excludedAttributeSet, filter, sortBy, sortOrder, startIndex, count);

Invocation.Builder builder = target().
path(ApiConstants.SEARCH_WITH_POST_PATH_EXTENSION).
request(ScimService.MEDIA_TYPE_SCIM_TYPE, APPLICATION_JSON_TYPE);
for (Map.Entry<String, List<Object>> header : headers.entrySet())
{
response.close();
String stringValue = StaticUtils.listToString(header.getValue(), ", ");
builder = builder.header(header.getKey(), stringValue);
}

return builder.post(Entity.entity(searchRequest, getContentType()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
* <li> {@code startIndex}: The index indicating the page number, if
* pagination is supported by the SCIM service.
* </ul>
* <br><br>
*
* An example list response takes the following form:
* <pre>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,71 @@
import com.unboundid.scim2.common.annotations.Schema;
import com.unboundid.scim2.common.annotations.Attribute;
import com.unboundid.scim2.common.BaseScimResource;
import com.unboundid.scim2.common.filters.Filter;

import java.util.Set;

import static com.unboundid.scim2.common.utils.ApiConstants.*;

/**
* Class representing a SCIM 2 search request.
* This class represents a SCIM 2.0 search request.
* <br><br>
*
* 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
* resources.
* <br><br>
*
* Search requests can include the following parameters to fine-tune the result
* set:
* <ul>
* <li> {@code filter}: A SCIM {@link Filter} that requests specific resources
* that match a given filter criteria.
* <li> {@code attributes}: A set of values indicating which attributes
* should be included in the response. For example, including "userName"
* would ensure that the returned resources will only display the value
* of the {@code userName} attribute. Note that the {@code id} attribute
* is always returned.
* <li> {@code excludedAttributes}: A set of values indicating attributes
* that should not be included on the returned resources.
* <li> {@code sortBy}: Indicates the attribute whose value should be used to
* sort the resources, if the SCIM service supports sorting.
* <li> {@code sortOrder}: The order that the {@code sortBy} parameter is
* applied. This may be set to "ascending" (the default) or "descending".
* <li> {@code startIndex}: The page number of the ListResponse, if the SCIM
* service provider supports pagination.
* <li> {@code count}: The maximum number of resources to return.
* </ul>
* <br><br>
*
* Search requests can be issued in two ways: with GET requests or POST
* requests. A GET search request involves the use of HTTP query parameters,
* e.g.:
* <pre>
* GET https://example.com/v2/Users?filter=userName eq "K.Dot"
*
* // Sometimes URLs must encode characters.
* GET https://example.com/v2/Users?filter=userName%20eq%20%K.Dot%22
* </pre>
*
* A POST search request is typically issued to an endpoint ending in
* {@code /.search}, e.g., {@code /Users/.search}. This allows clients to pass
* search criteria in a JSON body instead of passing them as query parameters.
* An example request is shown below:
* <pre>
* POST https://example.com/v2/Users/.search
*
* {
* "schemas": [ "urn:ietf:params:scim:api:messages:2.0:SearchRequest" ],
* "attributes": [ "userName", "displayName" ],
* "filter": "userName eq \"K.Dot\"",
* "count": 2
* }
* </pre>
*/
@SuppressWarnings("JavadocLinkAsPlainText")
@Schema(id="urn:ietf:params:scim:api:messages:2.0:SearchRequest",
name="Search Operation", description = "SCIM 2.0 Search Request")
public final class SearchRequest extends BaseScimResource
Expand Down
Loading

0 comments on commit e43e23a

Please sign in to comment.