001/*
002 * Copyright 2015-2024 Ping Identity Corporation
003 *
004 * This program is free software; you can redistribute it and/or modify
005 * it under the terms of the GNU General Public License (GPLv2 only)
006 * or the terms of the GNU Lesser General Public License (LGPLv2.1 only)
007 * as published by the Free Software Foundation.
008 *
009 * This program is distributed in the hope that it will be useful,
010 * but WITHOUT ANY WARRANTY; without even the implied warranty of
011 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
012 * GNU General Public License for more details.
013 *
014 * You should have received a copy of the GNU General Public License
015 * along with this program; if not, see <http://www.gnu.org/licenses>.
016 */
017
018package com.unboundid.scim2.server.utils;
019
020import com.fasterxml.jackson.databind.JsonNode;
021import com.fasterxml.jackson.databind.node.ArrayNode;
022import com.fasterxml.jackson.databind.node.ObjectNode;
023import com.unboundid.scim2.common.GenericScimResource;
024import com.unboundid.scim2.common.Path;
025import com.unboundid.scim2.common.ScimResource;
026import com.unboundid.scim2.common.annotations.NotNull;
027import com.unboundid.scim2.common.annotations.Nullable;
028import com.unboundid.scim2.common.exceptions.BadRequestException;
029import com.unboundid.scim2.common.messages.PatchOperation;
030import com.unboundid.scim2.common.types.Meta;
031import com.unboundid.scim2.common.utils.StaticUtils;
032
033import jakarta.ws.rs.core.MultivaluedMap;
034import jakarta.ws.rs.core.UriBuilder;
035import jakarta.ws.rs.core.UriInfo;
036import java.net.URI;
037import java.util.Collections;
038import java.util.Iterator;
039import java.util.LinkedHashMap;
040import java.util.LinkedHashSet;
041import java.util.Map;
042import java.util.Set;
043
044import static com.unboundid.scim2.common.utils.ApiConstants.*;
045
046/**
047 * Utility to prepare a resource to return to the client. This includes:
048 *
049 * <ul>
050 *   <li>
051 *     Returning the attributes based on the returned constraint of the
052 *     attribute definition in the schema.
053 *   </li>
054 *   <li>
055 *     Returning the attributes requested by the client using the request
056 *     resource as well as the attributes or excludedAttributes query parameter.
057 *   </li>
058 *   <li>
059 *     Setting the meta.resourceType and meta.location attributes if not
060 *     already set.
061 *   </li>
062 * </ul>
063 */
064public class ResourcePreparer<T extends ScimResource>
065{
066  @NotNull
067  private final ResourceTypeDefinition resourceType;
068
069  @NotNull
070  private final URI baseUri;
071
072  @NotNull
073  private final Set<Path> queryAttributes;
074
075  private final boolean excluded;
076
077  /**
078   * Create a new ResourcePreparer for preparing returned resources for a
079   * SCIM operation.
080   *
081   * @param resourceType The resource type definition for resources to prepare.
082   * @param requestUriInfo The UriInfo for the request.
083   * @throws BadRequestException If an attribute path specified by attributes
084   * and excludedAttributes is invalid.
085   */
086  public ResourcePreparer(@NotNull final ResourceTypeDefinition resourceType,
087                          @NotNull final UriInfo requestUriInfo)
088      throws BadRequestException
089  {
090    this(resourceType,
091        requestUriInfo.getQueryParameters().getFirst(
092            QUERY_PARAMETER_ATTRIBUTES),
093        requestUriInfo.getQueryParameters().getFirst(
094            QUERY_PARAMETER_EXCLUDED_ATTRIBUTES),
095        requestUriInfo.getBaseUriBuilder().
096            path(resourceType.getEndpoint()).
097            buildFromMap(singleValuedMapFromMultivaluedMap(
098                requestUriInfo.getPathParameters())));
099  }
100
101  @NotNull
102  private static Map<String, String> singleValuedMapFromMultivaluedMap(
103      @NotNull final MultivaluedMap<String, String> multivaluedMap)
104  {
105    final Map<String, String> returnMap = new LinkedHashMap<String, String>();
106    for (String k : multivaluedMap.keySet())
107    {
108      returnMap.put(k, multivaluedMap.getFirst(k));
109    }
110
111    return returnMap;
112  }
113
114  /**
115   * Private constructor used by unit-test.
116   *
117   * @param resourceType The resource type definition for resources to prepare.
118   * @param attributesString The attributes query param.
119   * @param excludedAttributesString The excludedAttributes query param.
120   * @param baseUri The resource type base URI.
121   */
122  ResourcePreparer(@NotNull final ResourceTypeDefinition resourceType,
123                   @NotNull final String attributesString,
124                   @Nullable final String excludedAttributesString,
125                   @NotNull final URI baseUri)
126      throws BadRequestException
127  {
128    if(attributesString != null && !attributesString.isEmpty())
129    {
130      Set<String> attributeSet = StaticUtils.arrayToSet(
131          StaticUtils.splitCommaSeparatedString(attributesString));
132      this.queryAttributes = new LinkedHashSet<Path>(attributeSet.size());
133      for(String attribute : attributeSet)
134      {
135        Path normalizedPath;
136        try
137        {
138          normalizedPath = resourceType.normalizePath(
139              Path.fromString(attribute)).withoutFilters();
140        }
141        catch (BadRequestException e)
142        {
143          throw BadRequestException.invalidValue("'" + attribute +
144              "' is not a valid value for the attributes parameter: " +
145              e.getMessage());
146        }
147        this.queryAttributes.add(normalizedPath);
148
149      }
150      this.excluded = false;
151    }
152    else if(excludedAttributesString != null &&
153        !excludedAttributesString.isEmpty())
154    {
155      Set<String> attributeSet = StaticUtils.arrayToSet(
156          StaticUtils.splitCommaSeparatedString(excludedAttributesString));
157      this.queryAttributes = new LinkedHashSet<Path>(attributeSet.size());
158      for(String attribute : attributeSet)
159      {
160        Path normalizedPath;
161        try
162        {
163          normalizedPath = resourceType.normalizePath(
164              Path.fromString(attribute)).withoutFilters();
165        }
166        catch (BadRequestException e)
167        {
168          throw BadRequestException.invalidValue("'" + attribute +
169              "' is not a valid value for the excludedAttributes parameter: " +
170              e.getMessage());
171        }
172        this.queryAttributes.add(normalizedPath);
173      }
174      this.excluded = true;
175    }
176    else
177    {
178      this.queryAttributes = Collections.emptySet();
179      this.excluded = true;
180    }
181    this.resourceType = resourceType;
182    this.baseUri = baseUri;
183  }
184
185  /**
186   * Trim attributes of the resources returned from a search or retrieve
187   * operation based on schema and the request parameters.
188   *
189   * @param returnedResource The resource to return.
190   * @return The trimmed resource ready to return to the client.
191   */
192  @NotNull
193  public GenericScimResource trimRetrievedResource(
194      @NotNull final T returnedResource)
195  {
196    return trimReturned(returnedResource, null, null);
197  }
198
199  /**
200   * Trim attributes of the resources returned from a create operation based on
201   * schema as well as the request resource and request parameters.
202   *
203   * @param returnedResource The resource to return.
204   * @param requestResource The resource in the create request or
205   *                        {@code null} if not available.
206   * @return The trimmed resource ready to return to the client.
207   */
208  @NotNull
209  public GenericScimResource trimCreatedResource(
210      @NotNull final T returnedResource,
211      @Nullable final T requestResource)
212  {
213    return trimReturned(returnedResource, requestResource, null);
214  }
215
216  /**
217   * Trim attributes of the resources returned from a replace operation based on
218   * schema as well as the request resource and request parameters.
219   *
220   * @param returnedResource The resource to return.
221   * @param requestResource The resource in the replace request or
222   *                        {@code null} if not available.
223   * @return The trimmed resource ready to return to the client.
224   */
225  @NotNull
226  public GenericScimResource trimReplacedResource(
227      @NotNull final T returnedResource,
228      @Nullable final T requestResource)
229  {
230    return trimReturned(returnedResource, requestResource, null);
231  }
232
233  /**
234   * Trim attributes of the resources returned from a modify operation based on
235   * schema as well as the patch request and request parameters.
236   *
237   * @param returnedResource The resource to return.
238   * @param patchOperations The operations in the patch request or
239   *                        {@code null} if not available.
240   * @return The trimmed resource ready to return to the client.
241   */
242  @NotNull
243  public GenericScimResource trimModifiedResource(
244      @NotNull final T returnedResource,
245      @Nullable final Iterable<PatchOperation> patchOperations)
246  {
247    return trimReturned(returnedResource, null, patchOperations);
248  }
249
250  /**
251   * Sets the meta.resourceType and meta.location metadata attribute values.
252   *
253   * @param returnedResource The resource to set the attributes.
254   */
255  public void setResourceTypeAndLocation(@NotNull final T returnedResource)
256  {
257    Meta meta = returnedResource.getMeta();
258
259    boolean metaUpdated = false;
260    if(meta == null)
261    {
262      meta = new Meta();
263    }
264
265    if(meta.getResourceType() == null)
266    {
267      meta.setResourceType(resourceType.getName());
268      metaUpdated = true;
269    }
270
271    if(meta.getLocation() == null)
272    {
273      String id = returnedResource.getId();
274      if (id != null)
275      {
276        UriBuilder locationBuilder = UriBuilder.fromUri(baseUri);
277        locationBuilder.segment(ServerUtils.encodeTemplateNames(id));
278        meta.setLocation(locationBuilder.build());
279      }
280      else
281      {
282        meta.setLocation(baseUri);
283      }
284      metaUpdated = true;
285    }
286
287    if(metaUpdated)
288    {
289      returnedResource.setMeta(meta);
290    }
291  }
292
293  /**
294   * Trim attributes of the resources to return based on schema and the client
295   * request.
296   *
297   * @param returnedResource The resource to return.
298   * @param requestResource The resource in the PUT or POST request or
299   *                        {@code null} for other requests.
300   * @param patchOperations The patch operations in the PATCH request or
301   *                        {@code null} for other requests.
302   * @return The trimmed resource ready to return to the client.
303   */
304  @NotNull
305  private GenericScimResource trimReturned(
306      @NotNull final T returnedResource,
307      @Nullable final T requestResource,
308      @Nullable final Iterable<PatchOperation> patchOperations)
309  {
310    Set<Path> requestAttributes = Collections.emptySet();
311    if(requestResource != null)
312    {
313      ObjectNode requestObject =
314          requestResource.asGenericScimResource().getObjectNode();
315      requestAttributes = new LinkedHashSet<Path>();
316      collectAttributes(Path.root(), requestAttributes, requestObject);
317    }
318
319    if(patchOperations != null)
320    {
321      requestAttributes = new LinkedHashSet<Path>();
322      collectAttributes(requestAttributes, patchOperations);
323    }
324
325    setResourceTypeAndLocation(returnedResource);
326    GenericScimResource genericReturnedResource =
327        returnedResource.asGenericScimResource();
328    ScimResourceTrimmer trimmer =
329        new ScimResourceTrimmer(resourceType, requestAttributes,
330                                queryAttributes, excluded);
331    GenericScimResource preparedResource =
332        new GenericScimResource(
333            trimmer.trimObjectNode(genericReturnedResource.getObjectNode()));
334    return preparedResource;
335  }
336
337  /**
338   * Collect a list of attributes in the object node.
339   *
340   * @param parentPath The parent path of attributes in the object.
341   * @param paths The set of paths to add to.
342   * @param objectNode The object node to collect from.
343   */
344  private void collectAttributes(@NotNull final Path parentPath,
345                                 @NotNull final Set<Path> paths,
346                                 @NotNull final ObjectNode objectNode)
347  {
348    Iterator<Map.Entry<String, JsonNode>> i = objectNode.fields();
349    while(i.hasNext())
350    {
351      Map.Entry<String, JsonNode> field = i.next();
352      Path path = parentPath.attribute(field.getKey());
353      if(path.size() > 1 || path.getSchemaUrn() == null)
354      {
355        // Don't add a path for the extension schema object itself.
356        paths.add(path);
357      }
358      if (field.getValue().isArray())
359      {
360        collectAttributes(path, paths, (ArrayNode) field.getValue());
361      }
362      else if (field.getValue().isObject())
363      {
364        collectAttributes(path, paths, (ObjectNode) field.getValue());
365      }
366    }
367  }
368
369  /**
370   * Collect a list of attributes in the array node.
371   *
372   * @param parentPath The parent path of attributes in the array.
373   * @param paths The set of paths to add to.
374   * @param arrayNode The array node to collect from.
375   */
376  private void collectAttributes(@NotNull final Path parentPath,
377                                 @NotNull final Set<Path> paths,
378                                 @NotNull final ArrayNode arrayNode)
379  {
380    for(JsonNode value : arrayNode)
381    {
382      if(value.isArray())
383      {
384        collectAttributes(parentPath, paths, (ArrayNode) value);
385      }
386      else if(value.isObject())
387      {
388        collectAttributes(parentPath, paths, (ObjectNode) value);
389      }
390    }
391  }
392
393  /**
394   * Collect a list of attributes in the patch operation.
395   *
396   * @param paths The set of paths to add to.
397   * @param patchOperations The patch operation to collect attributes from.
398   */
399  private void collectAttributes(
400      @NotNull final Set<Path> paths,
401      @NotNull final Iterable<PatchOperation> patchOperations)
402
403  {
404    for(PatchOperation patchOperation : patchOperations)
405    {
406      Path path = Path.root();
407      if(patchOperation.getPath() != null)
408      {
409        path = resourceType.normalizePath(patchOperation.getPath()).
410            withoutFilters();
411        paths.add(path);
412      }
413      if(patchOperation.getJsonNode() != null)
414      {
415        if(patchOperation.getJsonNode().isArray())
416        {
417          collectAttributes(
418              path, paths, (ArrayNode) patchOperation.getJsonNode());
419        }
420        else if(patchOperation.getJsonNode().isObject())
421        {
422          collectAttributes(
423              path, paths, (ObjectNode) patchOperation.getJsonNode());
424        }
425      }
426    }
427  }
428}