001/*
002 * Copyright 2012-2013 UnboundID Corp.
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.scim.wink;
019
020import com.unboundid.scim.data.BaseResource;
021import com.unboundid.scim.schema.ResourceDescriptor;
022import com.unboundid.scim.sdk.BulkContentHandler;
023import com.unboundid.scim.sdk.BulkException;
024import com.unboundid.scim.sdk.BulkOperation;
025import com.unboundid.scim.sdk.BulkOperation.Method;
026import com.unboundid.scim.sdk.BulkStreamResponse;
027import com.unboundid.scim.sdk.Debug;
028import com.unboundid.scim.sdk.DeleteResourceRequest;
029import com.unboundid.scim.sdk.InvalidResourceException;
030import com.unboundid.scim.sdk.OAuthTokenHandler;
031import com.unboundid.scim.sdk.PatchResourceRequest;
032import com.unboundid.scim.sdk.PostResourceRequest;
033import com.unboundid.scim.sdk.PutResourceRequest;
034import com.unboundid.scim.sdk.SCIMAttribute;
035import com.unboundid.scim.sdk.SCIMAttributeValue;
036import com.unboundid.scim.sdk.SCIMBackend;
037import com.unboundid.scim.sdk.SCIMException;
038import com.unboundid.scim.sdk.SCIMObject;
039import com.unboundid.scim.sdk.SCIMQueryAttributes;
040import com.unboundid.scim.sdk.ServerErrorException;
041import com.unboundid.scim.sdk.Status;
042import com.unboundid.scim.sdk.UnauthorizedException;
043
044import static com.unboundid.scim.wink.AbstractSCIMResource.validateOAuthToken;
045
046import javax.ws.rs.core.MediaType;
047import javax.ws.rs.core.Response;
048import javax.ws.rs.core.UriBuilder;
049import java.util.ArrayList;
050import java.util.Arrays;
051import java.util.Collection;
052import java.util.HashMap;
053import java.util.HashSet;
054import java.util.Map;
055import java.util.Set;
056import java.util.concurrent.atomic.AtomicReference;
057
058
059/**
060 * This class implements the bulk operation handler to process bulk operation
061 * requests in the SCIM server.
062 * The original purpose of the BulkContentHandler interface was to allow
063 * each operation to be processed as soon as it had been read so that
064 * we do not have to hold the entire content of a bulk request in
065 * memory. However, there are two issues making that approach infeasible:
066 * <ol>
067 * <li>Since JSON objects are unordered, the failOnErrors value could
068 * conceivably come after the Operations array, which would be too late.</li>
069 * <li>It would not be possible to reject a request that exceeded the
070 * maxOperations setting without processing any operations.</li>
071 * </ol>
072 */
073public class BulkContentRequestHandler extends BulkContentHandler
074{
075  /**
076   * The SCIM application.
077   */
078  private final SCIMApplication application;
079
080  /**
081   * The request context for the bulk request.
082   */
083  private final RequestContext requestContext;
084
085  /**
086   * The SCIM backend to process the operations.
087   */
088  private final SCIMBackend backend;
089
090  /**
091   * The OAuth 2.0 bearer token handler. This may be null.
092   */
093  private final OAuthTokenHandler tokenHandler;
094
095  /**
096   * The bulk stream response to write the operation responses to.
097   */
098  private final BulkStreamResponse bulkStreamResponse;
099
100  /**
101   * A map from bulkId to resourceID.
102   */
103  private final Map<String,String> resourceIDs;
104
105  /**
106   * A set containing the unresolved bulkId data references for the latest
107   * bulk operation.
108   */
109  private final Set<String> unresolvedBulkIdRefs;
110
111
112  private int errorCount = 0;
113
114  /**
115   * The set of defined bulkIds from all operations.
116   */
117  private final Set<String> bulkIds;
118
119
120
121  /**
122   * The number of errors that the Service Provider will accept before the
123   * operation is terminated and an error response is returned. The default
124   * is to continue performing as many changes as possible without regard to
125   * failures.
126   */
127  private int failOnErrors = Integer.MAX_VALUE;
128
129
130
131  /**
132   * Create a new instance of this bulk operation handler.
133   *
134   * @param application         The SCIM application.
135   * @param requestContext      The request context for the bulk request.
136   * @param backend             The SCIM backend to process the operations.
137   * @param bulkStreamResponse  The bulk stream response to write response
138   *                            operations to.
139   * @param tokenHandler        The OAuth token handler implementation to use.
140   */
141  public BulkContentRequestHandler(
142      final SCIMApplication application,
143      final RequestContext requestContext,
144      final SCIMBackend backend,
145      final BulkStreamResponse bulkStreamResponse,
146      final OAuthTokenHandler tokenHandler)
147  {
148    this.application        = application;
149    this.requestContext     = requestContext;
150    this.backend            = backend;
151    this.tokenHandler       = tokenHandler;
152    this.bulkStreamResponse = bulkStreamResponse;
153
154    resourceIDs = new HashMap<String, String>();
155    unresolvedBulkIdRefs = new HashSet<String>();
156    bulkIds = new HashSet<String>();
157  }
158
159
160
161  /**
162   * {@inheritDoc}
163   */
164  public void handleFailOnErrors(final int failOnErrors)
165  {
166    this.failOnErrors = failOnErrors;
167  }
168
169
170
171  /**
172   * {@inheritDoc}
173   */
174  @Override
175  public String transformValue(final int opIndex, final String value)
176  {
177    if (value.startsWith("bulkId:"))
178    {
179      final String bulkId = value.substring(7);
180      final String resourceID = resourceIDs.get(bulkId);
181      if (resourceID != null)
182      {
183        return resourceID;
184      }
185      else
186      {
187        unresolvedBulkIdRefs.add(bulkId);
188      }
189    }
190
191    return value;
192  }
193
194
195
196  /**
197   * {@inheritDoc}
198   */
199  @Override
200  public ResourceDescriptor getResourceDescriptor(final String endpoint)
201  {
202    return backend.getResourceDescriptor(endpoint);
203  }
204
205
206
207  /**
208   * {@inheritDoc}
209   */
210  public void handleOperation(final int opIndex,
211                              final BulkOperation bulkOperation)
212      throws BulkException, SCIMException
213  {
214    if (errorCount < failOnErrors)
215    {
216      final BulkOperation response = processOperation(bulkOperation);
217      unresolvedBulkIdRefs.clear();
218      bulkStreamResponse.writeBulkOperation(response);
219    }
220  }
221
222
223
224  /**
225   * {@inheritDoc}
226   */
227  public boolean handleException(final int opIndex,
228                                 final BulkException bulkException)
229      throws SCIMException
230  {
231    Debug.debugException(bulkException);
232    if (errorCount < failOnErrors)
233    {
234      int statusCode = bulkException.getCause().getStatusCode();
235      String statusMessage = bulkException.getCause().getMessage();
236
237      final Status status =
238          new Status(String.valueOf(statusCode), statusMessage);
239
240      final Method method = bulkException.getMethod();
241
242      // The bulk exception contains the path from the request. We just
243      // need to prepend the URL base.
244      String location = null;
245      if (method != BulkOperation.Method.POST)
246      {
247        final UriBuilder locationBuilder =
248            UriBuilder.fromUri(requestContext.getUriInfo().getBaseUri());
249        if (bulkException.getPath() != null)
250        {
251          locationBuilder.path(bulkException.getPath());
252        }
253        location = locationBuilder.build().toString();
254      }
255
256      BulkOperation response = BulkOperation.createResponse(
257          method, bulkException.getBulkId(),
258          location, status);
259      bulkStreamResponse.writeBulkOperation(response);
260      errorCount++;
261      return errorCount != failOnErrors;
262    }
263    else
264    {
265      return false;
266    }
267  }
268
269
270
271  /**
272   * Process an operation from a bulk request.
273   *
274   * @param operation       The operation to be processed from the bulk request.
275   *
276   * @return  The operation response.
277   * @throws  BulkException  If an error occurs while processing the individual
278   *                         operation within the bulk operation.
279   */
280  private BulkOperation processOperation(final BulkOperation operation)
281      throws BulkException
282  {
283    final Method method = operation.getMethod();
284    final String bulkId = operation.getBulkId();
285    final String path = operation.getPath();
286    BaseResource resource = operation.getData();
287
288    int statusCode = 200;
289    String location = null;
290    String endpoint = null;
291    String resourceID = null;
292
293    final ResourceDescriptor descriptor;
294    final ResourceStats resourceStats;
295    try
296    {
297      if (method == null)
298      {
299        throw new InvalidResourceException(
300            "The bulk operation does not specify a HTTP method");
301      }
302
303      try
304      {
305        Method.valueOf(method.name());
306      }
307      catch (IllegalArgumentException e)
308      {
309        throw new BulkException(SCIMException.createException(
310            405, "The bulk operation specifies an invalid " +
311            "HTTP method '" + method + "'. Allowed methods are " +
312            Arrays.asList(BulkOperation.Method.values())),
313            method, bulkId, path);
314      }
315
316      if (path == null)
317      {
318        throw new InvalidResourceException(
319            "The bulk operation does not specify a path");
320      }
321
322      if (path != null)
323      {
324        // Parse the path into an endpoint and optional resource ID.
325        int startPos = 0;
326        if (path.charAt(startPos) == '/')
327        {
328          startPos++;
329        }
330
331        int endPos = path.indexOf('/', startPos);
332        if (endPos == -1)
333        {
334          endPos = path.length();
335        }
336
337        endpoint = path.substring(startPos, endPos);
338
339        if (endPos < path.length() - 1)
340        {
341          resourceID = path.substring(endPos+1);
342        }
343
344        if (method == BulkOperation.Method.POST)
345        {
346          if (resourceID != null)
347          {
348            throw new InvalidResourceException(
349                "The bulk operation has method POST but the path includes" +
350                "a resource ID");
351          }
352        }
353        else
354        {
355          if (resourceID == null)
356          {
357            throw new InvalidResourceException(
358                "The bulk operation does not have a resource ID in " +
359                "the path");
360          }
361
362          if (resourceID.startsWith("bulkId:"))
363          {
364            final String ref = resourceID.substring(7);
365            resourceID = resourceIDs.get(ref);
366            if (resourceID == null)
367            {
368              throw SCIMException.createException(
369                  409, "Cannot resolve bulkId reference '" + ref + "'");
370            }
371          }
372        }
373      }
374
375      descriptor = getResourceDescriptor(endpoint);
376      if (descriptor == null)
377      {
378        throw new InvalidResourceException(
379            "The bulk operation specifies an unknown resource " +
380            "endpoint '" + endpoint + "'");
381      }
382
383      resourceStats = application.getStatsForResource(descriptor.getName());
384      if (resourceStats == null)
385      {
386        throw new ServerErrorException(
387            "Cannot find resource stats for resource '" +
388            descriptor.getName() + "'");
389      }
390    }
391    catch (SCIMException e)
392    {
393      throw new BulkException(e, method, bulkId, path);
394    }
395
396    final UriBuilder locationBuilder =
397        UriBuilder.fromUri(requestContext.getUriInfo().getBaseUri());
398    locationBuilder.path(path);
399
400    try
401    {
402      if (method == BulkOperation.Method.POST && bulkId == null)
403      {
404        throw new InvalidResourceException(
405            "The bulk operation has method POST but does not " +
406            "specify a bulkId");
407      }
408
409      if (bulkId != null)
410      {
411        if (!bulkIds.add(bulkId))
412        {
413          throw new InvalidResourceException(
414              "The bulk operation defines a duplicate bulkId '" +
415              bulkId + "'");
416        }
417      }
418
419      if (method != BulkOperation.Method.DELETE && resource == null)
420      {
421        throw new InvalidResourceException(
422            "The bulk operation does not have any resource data");
423      }
424
425      if (!unresolvedBulkIdRefs.isEmpty())
426      {
427        throw SCIMException.createException(
428            409, "Cannot resolve bulkId references "
429                 + unresolvedBulkIdRefs);
430      }
431
432      if (requestContext.getConsumeMediaType().equals(
433          MediaType.APPLICATION_JSON_TYPE))
434      {
435        switch (method)
436        {
437          case POST:
438            resourceStats.incrementStat(ResourceStats.POST_CONTENT_JSON);
439            break;
440          case PUT:
441            resourceStats.incrementStat(ResourceStats.PUT_CONTENT_JSON);
442            break;
443          case PATCH:
444            resourceStats.incrementStat(ResourceStats.PATCH_CONTENT_JSON);
445            break;
446        }
447      }
448      else
449      {
450        switch (method)
451        {
452          case POST:
453            resourceStats.incrementStat(ResourceStats.POST_CONTENT_XML);
454            break;
455          case PUT:
456            resourceStats.incrementStat(ResourceStats.PUT_CONTENT_XML);
457            break;
458          case PATCH:
459            resourceStats.incrementStat(ResourceStats.PATCH_CONTENT_XML);
460        }
461      }
462
463      // Request no attributes because we will not provide the resource in
464      // the response.
465      final SCIMQueryAttributes queryAttributes =
466          new SCIMQueryAttributes(descriptor, "");
467
468      switch (method)
469      {
470        case POST:
471          PostResourceRequest postResourceRequest =
472               new PostResourceRequest(requestContext.getUriInfo().getBaseUri(),
473                                       requestContext.getAuthID(),
474                                       descriptor,
475                                       resource.getScimObject(),
476                                       queryAttributes,
477                                       requestContext.getRequest());
478
479          if (requestContext.getAuthID() == null)
480          {
481            AtomicReference<String> authIDRef = new AtomicReference<String>();
482            Response response = validateOAuthToken(requestContext,
483                                  postResourceRequest, authIDRef, tokenHandler);
484            if (response != null)
485            {
486              throw new UnauthorizedException("Invalid credentials");
487            }
488            else
489            {
490              String authID = authIDRef.get();
491              postResourceRequest = new PostResourceRequest(
492                              requestContext.getUriInfo().getBaseUri(),
493                              authID, descriptor, resource.getScimObject(),
494                              queryAttributes,
495                              requestContext.getRequest());
496            }
497          }
498
499          final BaseResource postedResource =
500                  backend.postResource(postResourceRequest);
501
502          resourceID = postedResource.getId();
503          locationBuilder.path(resourceID);
504          statusCode = 201;
505          resourceStats.incrementStat(ResourceStats.POST_OK);
506          break;
507
508        case PUT:
509          PutResourceRequest putResourceRequest =
510                new PutResourceRequest(requestContext.getUriInfo().getBaseUri(),
511                                       requestContext.getAuthID(),
512                                       descriptor,
513                                       resourceID,
514                                       resource.getScimObject(),
515                                       queryAttributes,
516                                       requestContext.getRequest());
517
518          if (requestContext.getAuthID() == null)
519          {
520            AtomicReference<String> authIDRef = new AtomicReference<String>();
521            Response response = validateOAuthToken(requestContext,
522                                   putResourceRequest, authIDRef, tokenHandler);
523            if (response != null)
524            {
525              throw new UnauthorizedException("Invalid credentials");
526            }
527            else
528            {
529              String authID = authIDRef.get();
530              putResourceRequest = new PutResourceRequest(
531                      requestContext.getUriInfo().getBaseUri(),
532                      authID, descriptor, resourceID, resource.getScimObject(),
533                      queryAttributes, requestContext.getRequest());
534            }
535          }
536
537          backend.putResource(putResourceRequest);
538          resourceStats.incrementStat(ResourceStats.PUT_OK);
539          break;
540
541        case PATCH:
542          PatchResourceRequest patchResourceRequest =
543              new PatchResourceRequest(requestContext.getUriInfo().getBaseUri(),
544                                       requestContext.getAuthID(),
545                                       descriptor,
546                                       resourceID,
547                                       resource.getScimObject(),
548                                       queryAttributes,
549                                       requestContext.getRequest());
550
551          if (requestContext.getAuthID() == null)
552          {
553            AtomicReference<String> authIDRef = new AtomicReference<String>();
554            Response response = validateOAuthToken(requestContext,
555                                 patchResourceRequest, authIDRef, tokenHandler);
556            if (response != null)
557            {
558              throw new UnauthorizedException("Invalid credentials");
559            }
560            else
561            {
562              String authID = authIDRef.get();
563              patchResourceRequest = new PatchResourceRequest(
564                      requestContext.getUriInfo().getBaseUri(),
565                      authID, descriptor, resourceID, resource.getScimObject(),
566                      queryAttributes, requestContext.getRequest());
567            }
568          }
569
570          backend.patchResource(patchResourceRequest);
571          resourceStats.incrementStat(ResourceStats.PATCH_OK);
572          break;
573
574        case DELETE:
575          DeleteResourceRequest deleteResourceRequest =
576             new DeleteResourceRequest(requestContext.getUriInfo().getBaseUri(),
577                                       requestContext.getAuthID(),
578                                       descriptor,
579                                       resourceID,
580                                       requestContext.getRequest());
581
582          if (requestContext.getAuthID() == null)
583          {
584            AtomicReference<String> authIDRef = new AtomicReference<String>();
585            Response response = validateOAuthToken(requestContext,
586                                deleteResourceRequest, authIDRef, tokenHandler);
587            if (response != null)
588            {
589              throw new UnauthorizedException("Invalid credentials");
590            }
591            else
592            {
593              String authID = authIDRef.get();
594              deleteResourceRequest = new DeleteResourceRequest(
595                      requestContext.getUriInfo().getBaseUri(),
596                      authID, descriptor, resourceID,
597                      requestContext.getRequest());
598            }
599          }
600
601          backend.deleteResource(deleteResourceRequest);
602          resourceStats.incrementStat(ResourceStats.DELETE_OK);
603          break;
604      }
605
606      if (bulkId != null)
607      {
608        resourceIDs.put(bulkId, resourceID);
609      }
610    }
611    catch (SCIMException e)
612    {
613      switch (method)
614      {
615        case POST:
616          resourceStats.incrementStat("post-" + e.getStatusCode());
617          break;
618        case PUT:
619          resourceStats.incrementStat("put-" + e.getStatusCode());
620          break;
621        case PATCH:
622          resourceStats.incrementStat("patch-" + e.getStatusCode());
623          break;
624        case DELETE:
625          resourceStats.incrementStat("delete-" + e.getStatusCode());
626          break;
627      }
628      throw new BulkException(e, method, bulkId, path);
629    }
630
631    if (requestContext.getProduceMediaType() ==
632        MediaType.APPLICATION_JSON_TYPE)
633    {
634      switch (method)
635      {
636        case POST:
637          resourceStats.incrementStat(ResourceStats.POST_RESPONSE_JSON);
638          break;
639        case PUT:
640          resourceStats.incrementStat(ResourceStats.PUT_RESPONSE_JSON);
641          break;
642        case PATCH:
643          resourceStats.incrementStat(ResourceStats.PATCH_RESPONSE_JSON);
644          break;
645      }
646    }
647    else if (requestContext.getProduceMediaType() ==
648             MediaType.APPLICATION_XML_TYPE)
649    {
650      switch (method)
651      {
652      case POST:
653        resourceStats.incrementStat(ResourceStats.POST_RESPONSE_XML);
654        break;
655      case PUT:
656        resourceStats.incrementStat(ResourceStats.PUT_RESPONSE_XML);
657        break;
658      case PATCH:
659        resourceStats.incrementStat(ResourceStats.PATCH_RESPONSE_XML);
660      }
661    }
662
663    // Set the location for all operations except an unsuccessful POST.
664    if (method != BulkOperation.Method.POST || statusCode == 201)
665    {
666      location = locationBuilder.build().toString();
667    }
668
669    final Status status =
670        new Status(String.valueOf(statusCode), null);
671
672    return BulkOperation.createResponse(method, bulkId, location, status);
673  }
674
675
676
677  /**
678   * Obtain a copy of the provided resource with each bulkId reference
679   * resolved to a resource ID.
680   *
681   * @param resource  The resource that may contain bulkId references.
682   *
683   * @return  A copy of the resource with bulkId references resolved.
684   *
685   * @throws SCIMException  If there are any undefined bulkId references.
686   */
687  private BaseResource resolveBulkIds(final BaseResource resource)
688      throws SCIMException
689  {
690    final SCIMObject src = resource.getScimObject();
691    final SCIMObject dst = new SCIMObject();
692
693    for (final String schema : src.getSchemas())
694    {
695      for (final SCIMAttribute a : src.getAttributes(schema))
696      {
697        dst.setAttribute(resolveBulkIds(a));
698      }
699    }
700
701    return new BaseResource(resource.getResourceDescriptor(), dst);
702  }
703
704
705
706  /**
707   * Obtain a copy of the provided SCIM attribute with each bulkId reference
708   * resolved to a resource ID.
709   *
710   * @param a  The attribute that may contain bulkId references.
711   *
712   * @return  A copy of the attribute with bulkId references resolved.
713   *
714   * @throws SCIMException  If there are any undefined bulkId references.
715   */
716  private SCIMAttribute resolveBulkIds(final SCIMAttribute a)
717      throws SCIMException
718  {
719    final SCIMAttributeValue[] srcValues = a.getValues();
720    final SCIMAttributeValue[] dstValues =
721        new SCIMAttributeValue[a.getValues().length];
722    for (int i = 0; i < srcValues.length; i++)
723    {
724      dstValues[i] = resolveBulkIds(srcValues[i]);
725    }
726
727    return SCIMAttribute.create(a.getAttributeDescriptor(), dstValues);
728  }
729
730
731
732  /**
733   * Obtain a copy of the provided SCIM attribute value with each bulkId
734   * reference resolved to a resource ID.
735   *
736   * @param v  The attribute value that may contain bulkId references.
737   *
738   * @return  A copy of the attribute value with bulkId references resolved.
739   *
740   * @throws SCIMException  If there are any undefined bulkId references.
741   */
742  private SCIMAttributeValue resolveBulkIds(final SCIMAttributeValue v)
743      throws SCIMException
744  {
745    if (v.isComplex())
746    {
747      final Collection<SCIMAttribute> srcAttrs = v.getAttributes().values();
748      final ArrayList<SCIMAttribute> dstAttrs =
749          new ArrayList<SCIMAttribute>(srcAttrs.size());
750      for (final SCIMAttribute a : srcAttrs)
751      {
752        dstAttrs.add(resolveBulkIds(a));
753      }
754
755      return SCIMAttributeValue.createComplexValue(dstAttrs);
756    }
757    else
758    {
759      final String s = v.getStringValue();
760      if (s.startsWith("bulkId:"))
761      {
762        final String bulkId = s.substring(7);
763        final String resourceID = resourceIDs.get(bulkId);
764        if (resourceID != null)
765        {
766          return SCIMAttributeValue.createStringValue(resourceID);
767        }
768        else
769        {
770          throw SCIMException.createException(
771              409, "Cannot resolve bulkId reference '" + bulkId + "'");
772        }
773      }
774      else
775      {
776        return v;
777      }
778    }
779  }
780}