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