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