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 to be permissive of improperly-cased
attributes in the response (e.g., "resources" instead of "Resources").
This is intended to broaden compatibility.

This change includes:
* Documentation updates to give more information about SearchRequests.
* Simplify the invoke() logic by including try-with-resources blocks.

Reviewer: vyhhuang
Reviewer: dougbulkley

JiraIssue: DS-49756
  • Loading branch information
kqarryzada committed Feb 27, 2025
1 parent 14e1197 commit 1c2f3ac
Show file tree
Hide file tree
Showing 7 changed files with 207 additions and 97 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ 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"`).

## 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 @@ -23,14 +23,70 @@
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>
*
* 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
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
import java.util.Set;

import static com.unboundid.scim2.common.utils.ApiConstants.MEDIA_TYPE_SCIM;
import static org.assertj.core.api.Assertions.assertThat;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.assertNull;
Expand Down Expand Up @@ -1168,6 +1169,34 @@ public void testBadErrorResult() throws Exception
}
}

/**
* This test validates the SDK's parsing of the ListResponse defined in
* {@link TestResourceEndpoint#testListResponseCaseSensitivity}.
*/
@Test
public void testListResponseParsingCaseSensitivity() throws Exception
{
final ScimService service = new ScimService(target());
ListResponse<UserResource> response =
service.searchRequest("Users/testListResponseCaseSensitivity")
.invoke(UserResource.class);

// Even though the attribute casing is varied, all named fields should
// have been successfully parsed.
assertThat(response.getSchemaUrns())
.hasSize(1)
.first()
.isEqualTo("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"));

// startIndex was not included, so it should not have a value.
assertThat(response.getStartIndex()).isNull();
}


/**
* Test that MethodNotAllowedExceptions are thrown properly.
Expand Down
Loading

0 comments on commit 1c2f3ac

Please sign in to comment.