001    /*
002     * Copyright 2011-2012 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    
018    package com.unboundid.scim.sdk;
019    
020    import com.unboundid.scim.data.Meta;
021    import com.unboundid.scim.data.ResourceFactory;
022    import com.unboundid.scim.data.BaseResource;
023    import com.unboundid.scim.marshal.Marshaller;
024    import com.unboundid.scim.marshal.Unmarshaller;
025    import com.unboundid.scim.marshal.json.JsonMarshaller;
026    import com.unboundid.scim.marshal.json.JsonUnmarshaller;
027    import com.unboundid.scim.marshal.xml.XmlMarshaller;
028    import com.unboundid.scim.marshal.xml.XmlUnmarshaller;
029    import com.unboundid.scim.schema.ResourceDescriptor;
030    
031    import org.apache.http.HttpException;
032    import org.apache.wink.client.ClientAuthenticationException;
033    import org.apache.wink.client.ClientConfigException;
034    import org.apache.wink.client.ClientResponse;
035    import org.apache.wink.client.ClientRuntimeException;
036    import org.apache.wink.client.ClientWebException;
037    import org.apache.wink.client.RestClient;
038    
039    import javax.ws.rs.WebApplicationException;
040    import javax.ws.rs.core.MediaType;
041    import javax.ws.rs.core.Response;
042    import javax.ws.rs.core.StreamingOutput;
043    import javax.ws.rs.core.UriBuilder;
044    import java.io.IOException;
045    import java.io.InputStream;
046    import java.io.OutputStream;
047    import java.net.URI;
048    import java.util.List;
049    
050    /**
051     * This class represents a SCIM endpoint (ie. Users, Groups, etc.) and handles
052     * all protocol-level interactions with the service provider. It acts as a
053     * helper class for invoking CRUD operations of resources and processing their
054     * results.
055     *
056     * @param <R> The type of resource instances handled by this SCIMEndpoint.
057     */
058    public class SCIMEndpoint<R extends BaseResource>
059    {
060      private final SCIMService scimService;
061      private final ResourceDescriptor resourceDescriptor;
062      private final ResourceFactory<R> resourceFactory;
063      private final Unmarshaller unmarshaller;
064      private final Marshaller marshaller;
065      private final MediaType contentType;
066      private final MediaType acceptType;
067      private final boolean[] overrides = new boolean[3];
068      private final RestClient client;
069    
070    
071      /**
072       * Create a SCIMEndpoint with the provided information.
073       *
074       * @param scimService The SCIMService to use.
075       * @param restClient The Wink REST client.
076       * @param resourceDescriptor The resource descriptor of this endpoint.
077       * @param resourceFactory The ResourceFactory that should be used to create
078       *                        resource instances.
079       */
080      SCIMEndpoint(final SCIMService scimService,
081                   final RestClient restClient,
082                   final ResourceDescriptor resourceDescriptor,
083                   final ResourceFactory<R> resourceFactory)
084      {
085        this.scimService = scimService;
086        this.client = restClient;
087        this.resourceDescriptor = resourceDescriptor;
088        this.resourceFactory = resourceFactory;
089        this.contentType = scimService.getContentType();
090        this.acceptType = scimService.getAcceptType();
091        this.overrides[0] = scimService.isOverridePut();
092        this.overrides[1] = scimService.isOverridePatch();
093        this.overrides[2] = scimService.isOverrideDelete();
094    
095        if (scimService.getContentType().equals(MediaType.APPLICATION_JSON_TYPE))
096        {
097          this.marshaller = new JsonMarshaller();
098        }
099        else
100        {
101          this.marshaller = new XmlMarshaller();
102        }
103    
104        if(scimService.getAcceptType().equals(MediaType.APPLICATION_JSON_TYPE))
105        {
106          this.unmarshaller = new JsonUnmarshaller();
107        }
108        else
109        {
110          this.unmarshaller = new XmlUnmarshaller();
111        }
112      }
113    
114    
115    
116      /**
117       * Constructs a new instance of a resource object which is empty. This
118       * method does not interact with the SCIM service. It creates a local object
119       * that may be provided to the {@link SCIMEndpoint#create} method after the
120       * attributes have been specified.
121       *
122       * @return  A new instance of a resource object.
123       */
124      public R newResource()
125      {
126        return resourceFactory.createResource(resourceDescriptor, new SCIMObject());
127      }
128    
129      /**
130       * Retrieves a resource instance given the ID.
131       *
132       * @param id The ID of the resource to retrieve.
133       * @return The retrieved resource.
134       * @throws SCIMException If an error occurs.
135       */
136      public R get(final String id)
137          throws SCIMException
138      {
139        return get(id, null, null);
140      }
141    
142      /**
143       * Retrieves a resource instance given the ID, only if the current version
144       * has been modified.
145       *
146       * @param id The ID of the resource to retrieve.
147       * @param etag The entity tag that indicates the entry should be returned
148       *             only if the entity tag of the current resource is different
149       *             from the provided value. A value of <code>null</code> indicates
150       *             unconditional return.
151       * @param requestedAttributes The attributes of the resource to retrieve.
152       * @return The retrieved resource or <code>null</code> if the requested
153       * resource has not been modified.
154       * @throws SCIMException If an error occurs.
155       */
156      public R get(final String id, final String etag,
157                   final String... requestedAttributes)
158          throws SCIMException
159      {
160        final UriBuilder uriBuilder = UriBuilder.fromUri(scimService.getBaseURL());
161        uriBuilder.path(resourceDescriptor.getEndpoint());
162    
163        // The ServiceProviderConfig is a special case where the id is not
164        // specified.
165        if (id != null)
166        {
167          uriBuilder.path(id);
168        }
169    
170        URI uri = uriBuilder.build();
171        org.apache.wink.client.Resource clientResource = client.resource(uri);
172        clientResource.accept(acceptType);
173        clientResource.contentType(contentType);
174        addAttributesQuery(clientResource, requestedAttributes);
175    
176        if(scimService.getUserAgent() != null)
177        {
178          clientResource.header("User-Agent", scimService.getUserAgent());
179        }
180    
181        if(etag != null && !etag.isEmpty())
182        {
183          clientResource.header("If-None-Match", etag);
184        }
185    
186        InputStream entity = null;
187        try
188        {
189          ClientResponse response = clientResource.get();
190          entity = response.getEntity(InputStream.class);
191    
192          if(response.getStatusType() == Response.Status.NOT_MODIFIED)
193          {
194            return null;
195          }
196          else if(response.getStatusType() == Response.Status.OK)
197          {
198            R resource = unmarshaller.unmarshal(entity, resourceDescriptor,
199                resourceFactory);
200            addMissingMetaData(response, resource);
201            return resource;
202          }
203          else
204          {
205            throw createErrorResponseException(response, entity);
206          }
207        }
208        catch(SCIMException e)
209        {
210          throw e;
211        }
212        catch(Exception e)
213        {
214          throw SCIMException.createException(getStatusCode(e), e.getMessage());
215        }
216        finally
217        {
218          try {
219            if (entity != null) {
220              entity.close();
221            }
222          } catch (IOException e) {
223            // Lets just log this and ignore.
224            Debug.debugException(e);
225          }
226        }
227      }
228    
229      /**
230       * Retrieves all resource instances that match the provided filter.
231       *
232       * @param filter The filter that should be used.
233       * @return The resource instances that match the provided filter.
234       * @throws SCIMException If an error occurs.
235       */
236      public Resources<R> query(final String filter)
237          throws SCIMException
238      {
239        return query(filter, null, null, null);
240      }
241    
242      /**
243       * Retrieves all resource instances that match the provided filter.
244       * Matching resources are returned sorted according to the provided
245       * SortParameters. PageParameters maybe used to specify the range of
246       * resource instances that are returned.
247       *
248       * @param filter The filter that should be used.
249       * @param sortParameters The sort parameters that should be used.
250       * @param pageParameters The page parameters that should be used.
251       * @param requestedAttributes The attributes of the resource to retrieve.
252       * @return The resource instances that match the provided filter.
253       * @throws SCIMException If an error occurs.
254       */
255      public Resources<R> query(final String filter,
256                                final SortParameters sortParameters,
257                                final PageParameters pageParameters,
258                                final String... requestedAttributes)
259          throws SCIMException
260      {
261        URI uri =
262            UriBuilder.fromUri(scimService.getBaseURL()).path(
263                resourceDescriptor.getEndpoint()).build();
264        org.apache.wink.client.Resource clientResource = client.resource(uri);
265        clientResource.accept(acceptType);
266        clientResource.contentType(contentType);
267        addAttributesQuery(clientResource, requestedAttributes);
268        if(scimService.getUserAgent() != null)
269        {
270          clientResource.header("User-Agent", scimService.getUserAgent());
271        }
272        if(filter != null)
273        {
274          clientResource.queryParam("filter", filter);
275        }
276        if(sortParameters != null)
277        {
278          clientResource.queryParam("sortBy",
279              sortParameters.getSortBy().toString());
280          if(!sortParameters.isAscendingOrder())
281          {
282            clientResource.queryParam("sortOrder", sortParameters.getSortOrder());
283          }
284        }
285        if(pageParameters != null)
286        {
287          clientResource.queryParam("startIndex",
288              String.valueOf(pageParameters.getStartIndex()));
289          if (pageParameters.getCount() > 0)
290          {
291            clientResource.queryParam("count",
292                                      String.valueOf(pageParameters.getCount()));
293          }
294        }
295    
296        InputStream entity = null;
297        try
298        {
299          ClientResponse response = clientResource.get();
300          entity = response.getEntity(InputStream.class);
301    
302          if(response.getStatusType() == Response.Status.OK)
303          {
304            return unmarshaller.unmarshalResources(entity, resourceDescriptor,
305                resourceFactory);
306          }
307          else
308          {
309            throw createErrorResponseException(response, entity);
310          }
311        }
312        catch(SCIMException e)
313        {
314          throw e;
315        }
316        catch(Exception e)
317        {
318          throw SCIMException.createException(getStatusCode(e), e.getMessage());
319        }
320        finally
321        {
322          try {
323            if (entity != null) {
324              entity.close();
325            }
326          } catch (IOException e) {
327            // Lets just log this and ignore.
328            Debug.debugException(e);
329          }
330        }
331      }
332    
333    
334    
335      /**
336       * Create the specified resource instance at the service provider.
337       *
338       * @param resource The resource to create.
339       * @return The newly inserted resource returned by the service provider.
340       * @throws SCIMException If an error occurs.
341       */
342      public R create(final R resource) throws SCIMException
343      {
344        return create(resource, null);
345      }
346    
347      /**
348       * Create the specified resource instance at the service provider and return
349       * only the specified attributes from the newly inserted resource.
350       *
351       * @param resource The resource to create.
352       * @param requestedAttributes The attributes of the newly inserted resource
353       *                            to retrieve.
354       * @return The newly inserted resource returned by the service provider.
355       * @throws SCIMException If an error occurs.
356       */
357      public R create(final R resource,
358                      final String... requestedAttributes)
359          throws SCIMException
360      {
361    
362        URI uri =
363            UriBuilder.fromUri(scimService.getBaseURL()).path(
364                resourceDescriptor.getEndpoint()).build();
365        org.apache.wink.client.Resource clientResource = client.resource(uri);
366        clientResource.accept(acceptType);
367        clientResource.contentType(contentType);
368        addAttributesQuery(clientResource, requestedAttributes);
369        if(scimService.getUserAgent() != null)
370        {
371          clientResource.header("User-Agent", scimService.getUserAgent());
372        }
373    
374        StreamingOutput output = new StreamingOutput() {
375          public void write(final OutputStream outputStream)
376              throws IOException, WebApplicationException {
377            try {
378              marshaller.marshal(resource, outputStream);
379            } catch (Exception e) {
380              throw new WebApplicationException(e, Response.Status.BAD_REQUEST);
381            }
382          }
383        };
384    
385    
386        InputStream entity = null;
387        try
388        {
389          ClientResponse response = clientResource.post(output);
390          entity = response.getEntity(InputStream.class);
391    
392          if(response.getStatusType() == Response.Status.CREATED)
393          {
394            R postedResource = unmarshaller.unmarshal(entity, resourceDescriptor,
395                resourceFactory);
396            addMissingMetaData(response, postedResource);
397            return postedResource;
398          }
399          else
400          {
401            throw createErrorResponseException(response, entity);
402          }
403        }
404        catch(SCIMException e)
405        {
406          throw e;
407        }
408        catch(Exception e)
409        {
410          throw SCIMException.createException(getStatusCode(e), e.getMessage());
411        }
412        finally
413        {
414          try {
415            if (entity != null) {
416              entity.close();
417            }
418          } catch (IOException e) {
419            // Lets just log this and ignore.
420            Debug.debugException(e);
421          }
422        }
423      }
424    
425      /**
426       * Update the existing resource with the one provided.
427       *
428       * @param resource The modified resource to be updated.
429       * @return The updated resource returned by the service provider.
430       * @throws SCIMException If an error occurs.
431       */
432      public R update(final R resource)
433          throws SCIMException
434      {
435        return update(resource, null, null);
436      }
437    
438      /**
439       * Update the existing resource with the one provided. This update is
440       * conditional upon the provided entity tag matching the tag from the
441       * current resource. If (and only if) they match, the update will be
442       * performed.
443       *
444       * @param resource The modified resource to be updated.
445       * @param etag The entity tag value that is the expected value for the target
446       *             resource. A value of <code>null</code> will not set an
447       *             etag precondition and a value of "*" will perform an
448       *             unconditional update.
449       * @param requestedAttributes The attributes of updated resource
450       *                            to return.
451       * @return The updated resource returned by the service provider.
452       * @throws SCIMException If an error occurs.
453       */
454      public R update(final R resource, final String etag,
455                      final String... requestedAttributes)
456          throws SCIMException
457      {
458        String id = resource.getId();
459        if(id == null)
460        {
461          throw new InvalidResourceException("Resource must have a valid ID");
462        }
463        URI uri =
464            UriBuilder.fromUri(scimService.getBaseURL()).path(
465                resourceDescriptor.getEndpoint()).path(id).build();
466        org.apache.wink.client.Resource clientResource = client.resource(uri);
467        clientResource.accept(acceptType);
468        clientResource.contentType(contentType);
469        addAttributesQuery(clientResource, requestedAttributes);
470        if(scimService.getUserAgent() != null)
471        {
472          clientResource.header("User-Agent", scimService.getUserAgent());
473        }
474        if(etag != null && !etag.isEmpty())
475        {
476          clientResource.header("If-Match", etag);
477        }
478    
479        StreamingOutput output = new StreamingOutput() {
480          public void write(final OutputStream outputStream)
481              throws IOException, WebApplicationException {
482            try {
483              marshaller.marshal(resource, outputStream);
484            } catch (Exception e) {
485              throw new WebApplicationException(e, Response.Status.BAD_REQUEST);
486            }
487          }
488        };
489    
490        InputStream entity = null;
491        try
492        {
493          ClientResponse response;
494          if(overrides[0])
495          {
496            clientResource.header("X-HTTP-Method-Override", "PUT");
497            response = clientResource.post(output);
498          }
499          else
500          {
501            response = clientResource.put(output);
502          }
503    
504          entity = response.getEntity(InputStream.class);
505    
506          if(response.getStatusType() == Response.Status.OK)
507          {
508            R postedResource = unmarshaller.unmarshal(entity, resourceDescriptor,
509                resourceFactory);
510            addMissingMetaData(response, postedResource);
511            return postedResource;
512          }
513          else
514          {
515            throw createErrorResponseException(response, entity);
516          }
517        }
518        catch(SCIMException e)
519        {
520          throw e;
521        }
522        catch(Exception e)
523        {
524          throw SCIMException.createException(getStatusCode(e), e.getMessage());
525        }
526        finally
527        {
528          try {
529            if (entity != null) {
530              entity.close();
531            }
532          } catch (IOException e) {
533            // Lets just log this and ignore.
534            Debug.debugException(e);
535          }
536        }
537      }
538    
539      /**
540       * Delete the resource instance specified by the provided ID.
541       *
542       * @param id The ID of the resource to delete.
543       * @throws SCIMException If an error occurs.
544       */
545      public void delete(final String id)
546          throws SCIMException
547      {
548        delete(id, null);
549      }
550    
551      /**
552       * Delete the resource instance specified by the provided ID. This delete is
553       * conditional upon the provided entity tag matching the tag from the
554       * current resource. If (and only if) they match, the delete will be
555       * performed.
556       *
557       * @param id The ID of the resource to delete.
558       * @param etag The entity tag value that is the expected value for the target
559       *             resource. A value of <code>null</code> will not set an
560       *             etag precondition and a value of "*" will perform an
561       *             unconditional delete.
562       * @throws SCIMException If an error occurs.
563       */
564      public void delete(final String id, final String etag)
565          throws SCIMException
566      {
567        URI uri =
568            UriBuilder.fromUri(scimService.getBaseURL()).path(
569                resourceDescriptor.getEndpoint()).path(id).build();
570        org.apache.wink.client.Resource clientResource = client.resource(uri);
571        clientResource.accept(acceptType);
572        clientResource.contentType(contentType);
573        if(scimService.getUserAgent() != null)
574        {
575          clientResource.header("User-Agent", scimService.getUserAgent());
576        }
577        if(etag != null && !etag.isEmpty())
578        {
579          clientResource.header("If-Match", etag);
580        }
581    
582    
583        InputStream entity = null;
584        try
585        {
586          ClientResponse response;
587          if(overrides[2])
588          {
589            clientResource.header("X-HTTP-Method-Override", "DELETE");
590            response = clientResource.post(null);
591          }
592          else
593          {
594            response = clientResource.delete();
595          }
596    
597          entity = response.getEntity(InputStream.class);
598    
599          if(response.getStatusType() != Response.Status.OK)
600          {
601            throw createErrorResponseException(response, entity);
602          }
603        }
604        catch(SCIMException e)
605        {
606          throw e;
607        }
608        catch(Exception e)
609        {
610          throw SCIMException.createException(getStatusCode(e), e.getMessage());
611        }
612        finally
613        {
614          try {
615            if (entity != null) {
616              entity.close();
617            }
618          } catch (IOException e) {
619            // Lets just log this and ignore.
620            Debug.debugException(e);
621          }
622        }
623      }
624    
625      /**
626       * Add the attributes query parameter to the client resource request.
627       *
628       * @param clientResource The Wink client resource.
629       * @param requestedAttributes The SCIM attributes to request.
630       */
631      private void addAttributesQuery(
632          final org.apache.wink.client.Resource clientResource,
633          final String... requestedAttributes)
634      {
635        if(requestedAttributes != null && requestedAttributes.length > 0)
636        {
637          StringBuilder stringBuilder = new StringBuilder();
638          for(int i = 0; i < requestedAttributes.length; i++)
639          {
640            stringBuilder.append(requestedAttributes[i]);
641            if(i < requestedAttributes.length - 1)
642            {
643              stringBuilder.append(",");
644            }
645          }
646          clientResource.queryParam("attributes", stringBuilder.toString());
647        }
648      }
649    
650      /**
651       * Add meta values from the response header to the meta complex attribute
652       * if they are missing.
653       *
654       * @param response The response from the service provider.
655       * @param resource The return resource instance.
656       */
657      private void addMissingMetaData(final ClientResponse response,
658                                      final R resource)
659      {
660        URI headerLocation = null;
661        String headerEtag = null;
662        List<String> values = response.getHeaders().get("Location");
663        if(values != null && !values.isEmpty())
664        {
665          headerLocation = URI.create(values.get(0));
666        }
667        values = response.getHeaders().get("Etag");
668        if(values != null && !values.isEmpty())
669        {
670          headerEtag = values.get(0);
671        }
672        Meta meta = resource.getMeta();
673        if(meta == null)
674        {
675          meta = new Meta(null, null, null, null);
676        }
677        boolean modified = false;
678        if(headerLocation != null && meta.getLocation() == null)
679        {
680          meta.setLocation(headerLocation);
681          modified = true;
682        }
683        if(headerEtag != null && meta.getVersion() == null)
684        {
685          meta.setVersion(headerEtag);
686          modified = true;
687        }
688        if(modified)
689        {
690          resource.setMeta(meta);
691        }
692      }
693    
694    
695    
696      /**
697       * Returns a SCIM exception representing the error response.
698       *
699       * @param response  The client response.
700       * @param entity    The response content.
701       *
702       * @return  The SCIM exception representing the error response.
703       */
704      private SCIMException createErrorResponseException(
705          final ClientResponse response,
706          final InputStream entity)
707      {
708        SCIMException scimException = null;
709    
710        if(entity != null)
711        {
712          try
713          {
714            scimException = unmarshaller.unmarshalError(entity);
715          }
716          catch (InvalidResourceException e)
717          {
718            // The response content could not be parsed as a SCIM error
719            // response, which is the case if the response is a more general
720            // HTTP error. It is better to just provide the HTTP response
721            // details in this case.
722            Debug.debugException(e);
723          }
724        }
725    
726        if(scimException == null)
727        {
728          scimException = SCIMException.createException(
729              response.getStatusCode(), response.getMessage());
730        }
731    
732        return scimException;
733      }
734    
735    
736      /**
737       * Tries to deduce the most appropriate HTTP response code from the given
738       * exception. This method expects most exceptions to be one of 3 or 4
739       * expected runtime exceptions that are common to Wink.
740       *
741       * @param e the Exception instance to analyze
742       * @return the most appropriate HTTP status code
743       */
744      private static int getStatusCode(final Exception e)
745      {
746        if(e instanceof ClientWebException)
747        {
748          ClientWebException cwe = (ClientWebException) e;
749          return cwe.getResponse().getStatusCode();
750        }
751        else if(e instanceof ClientAuthenticationException)
752        {
753          return 401;
754        }
755        else if(e instanceof ClientConfigException)
756        {
757          return 400;
758        }
759        else if(e instanceof ClientRuntimeException)
760        {
761          Throwable t = e.getCause();
762          if(t instanceof HttpException)
763          {
764            return 501;
765          }
766          else if(t instanceof IOException)
767          {
768            return 500;
769          }
770        }
771    
772        //Default
773        return 500;
774      }
775    }