001package org.hl7.fhir.r4.utils.client;
002
003/*-
004 * #%L
005 * org.hl7.fhir.r4
006 * %%
007 * Copyright (C) 2014 - 2019 Health Level 7
008 * %%
009 * Licensed under the Apache License, Version 2.0 (the "License");
010 * you may not use this file except in compliance with the License.
011 * You may obtain a copy of the License at
012 * 
013 *      http://www.apache.org/licenses/LICENSE-2.0
014 * 
015 * Unless required by applicable law or agreed to in writing, software
016 * distributed under the License is distributed on an "AS IS" BASIS,
017 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
018 * See the License for the specific language governing permissions and
019 * limitations under the License.
020 * #L%
021 */
022
023
024
025/*
026  Copyright (c) 2011+, HL7, Inc.
027  All rights reserved.
028
029  Redistribution and use in source and binary forms, with or without modification, 
030  are permitted provided that the following conditions are met:
031
032 * Redistributions of source code must retain the above copyright notice, this 
033     list of conditions and the following disclaimer.
034 * Redistributions in binary form must reproduce the above copyright notice, 
035     this list of conditions and the following disclaimer in the documentation 
036     and/or other materials provided with the distribution.
037 * Neither the name of HL7 nor the names of its contributors may be used to 
038     endorse or promote products derived from this software without specific 
039     prior written permission.
040
041  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 
042  ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 
043  WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 
044  IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 
045  INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 
046  NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 
047  PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 
048  WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 
049  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 
050  POSSIBILITY OF SUCH DAMAGE.
051
052 */
053
054
055import java.io.ByteArrayOutputStream;
056import java.io.IOException;
057import java.io.InputStream;
058import java.io.OutputStreamWriter;
059import java.io.UnsupportedEncodingException;
060import java.net.HttpURLConnection;
061import java.net.MalformedURLException;
062import java.net.URI;
063import java.net.URLConnection;
064import java.nio.charset.StandardCharsets;
065import java.text.ParseException;
066import java.text.SimpleDateFormat;
067import java.util.ArrayList;
068import java.util.Calendar;
069import java.util.Date;
070import java.util.List;
071import java.util.Map;
072
073import org.apache.commons.codec.binary.Base64;
074import org.apache.commons.io.IOUtils;
075import org.apache.commons.lang3.StringUtils;
076import org.apache.http.Header;
077import org.apache.http.HttpEntityEnclosingRequest;
078import org.apache.http.HttpHost;
079import org.apache.http.HttpRequest;
080import org.apache.http.HttpResponse;
081import org.apache.http.client.HttpClient;
082import org.apache.http.client.methods.HttpDelete;
083import org.apache.http.client.methods.HttpEntityEnclosingRequestBase;
084import org.apache.http.client.methods.HttpGet;
085import org.apache.http.client.methods.HttpOptions;
086import org.apache.http.client.methods.HttpPost;
087import org.apache.http.client.methods.HttpPut;
088import org.apache.http.client.methods.HttpUriRequest;
089import org.apache.http.conn.params.ConnRoutePNames;
090import org.apache.http.entity.ByteArrayEntity;
091import org.apache.http.impl.client.DefaultHttpClient;
092import org.apache.http.params.HttpConnectionParams;
093import org.apache.http.params.HttpParams;
094import org.hl7.fhir.r4.formats.IParser;
095import org.hl7.fhir.r4.formats.IParser.OutputStyle;
096import org.hl7.fhir.r4.formats.JsonParser;
097import org.hl7.fhir.r4.formats.XmlParser;
098import org.hl7.fhir.r4.model.Bundle;
099import org.hl7.fhir.r4.model.OperationOutcome;
100import org.hl7.fhir.r4.model.OperationOutcome.IssueSeverity;
101import org.hl7.fhir.r4.model.OperationOutcome.OperationOutcomeIssueComponent;
102import org.hl7.fhir.r4.model.Resource;
103import org.hl7.fhir.r4.model.ResourceType;
104import org.hl7.fhir.r4.utils.ResourceUtilities;
105
106/**
107 * Helper class handling lower level HTTP transport concerns.
108 * TODO Document methods.
109 * @author Claude Nanjo
110 */
111public class ClientUtils {
112
113  public static final String DEFAULT_CHARSET = "UTF-8";
114  public static final String HEADER_LOCATION = "location";
115
116  private HttpHost proxy;
117  private int timeout = 5000;
118  private String username;
119  private String password;
120  private ToolingClientLogger logger;
121
122  public HttpHost getProxy() {
123    return proxy;
124  }
125
126  public void setProxy(HttpHost proxy) {
127    this.proxy = proxy;
128  }
129
130  public int getTimeout() {
131    return timeout;
132  }
133
134  public void setTimeout(int timeout) {
135    this.timeout = timeout;
136  }
137
138  public String getUsername() {
139    return username;
140  }
141
142  public void setUsername(String username) {
143    this.username = username;
144  }
145
146  public String getPassword() {
147    return password;
148  }
149
150  public void setPassword(String password) {
151    this.password = password;
152  }
153
154  public <T extends Resource> ResourceRequest<T> issueOptionsRequest(URI optionsUri, String resourceFormat) {
155    HttpOptions options = new HttpOptions(optionsUri);
156    return issueResourceRequest(resourceFormat, options);
157  }
158
159  public <T extends Resource> ResourceRequest<T> issueGetResourceRequest(URI resourceUri, String resourceFormat) {
160    HttpGet httpget = new HttpGet(resourceUri);
161    return issueResourceRequest(resourceFormat, httpget);
162  }
163
164  public <T extends Resource> ResourceRequest<T> issuePutRequest(URI resourceUri, byte[] payload, String resourceFormat, List<Header> headers) {
165    HttpPut httpPut = new HttpPut(resourceUri);
166    return issueResourceRequest(resourceFormat, httpPut, payload, headers);
167  }
168
169  public <T extends Resource> ResourceRequest<T> issuePutRequest(URI resourceUri, byte[] payload, String resourceFormat) {
170    HttpPut httpPut = new HttpPut(resourceUri);
171    return issueResourceRequest(resourceFormat, httpPut, payload, null);
172  }
173
174  public <T extends Resource> ResourceRequest<T> issuePostRequest(URI resourceUri, byte[] payload, String resourceFormat, List<Header> headers) {
175    HttpPost httpPost = new HttpPost(resourceUri);
176    return issueResourceRequest(resourceFormat, httpPost, payload, headers);
177  }
178
179
180  public <T extends Resource> ResourceRequest<T> issuePostRequest(URI resourceUri, byte[] payload, String resourceFormat) {
181    return issuePostRequest(resourceUri, payload, resourceFormat, null);
182  }
183
184  public Bundle issueGetFeedRequest(URI resourceUri, String resourceFormat) {
185    HttpGet httpget = new HttpGet(resourceUri);
186    configureFhirRequest(httpget, resourceFormat);
187    HttpResponse response = sendRequest(httpget);
188    return unmarshalReference(response, resourceFormat);
189  }
190
191  private void setAuth(HttpRequest httpget) {
192    if (password != null) {
193      try {
194        byte[] b = Base64.encodeBase64((username+":"+password).getBytes("ASCII"));
195        String b64 = new String(b, StandardCharsets.US_ASCII);
196        httpget.setHeader("Authorization", "Basic " + b64);
197      } catch (UnsupportedEncodingException e) {
198      }
199    }
200  }
201
202  public Bundle postBatchRequest(URI resourceUri, byte[] payload, String resourceFormat) {
203    HttpPost httpPost = new HttpPost(resourceUri);
204    configureFhirRequest(httpPost, resourceFormat);
205    HttpResponse response = sendPayload(httpPost, payload, proxy);
206    return unmarshalFeed(response, resourceFormat);
207  }
208
209  public boolean issueDeleteRequest(URI resourceUri) {
210    HttpDelete deleteRequest = new HttpDelete(resourceUri);
211    HttpResponse response = sendRequest(deleteRequest);
212    int responseStatusCode = response.getStatusLine().getStatusCode();
213    boolean deletionSuccessful = false;
214    if(responseStatusCode == 204) {
215      deletionSuccessful = true;
216    }
217    return deletionSuccessful;
218  }
219
220  /***********************************************************
221   * Request/Response Helper methods
222   ***********************************************************/
223
224  protected <T extends Resource> ResourceRequest<T> issueResourceRequest(String resourceFormat, HttpUriRequest request) {
225    return issueResourceRequest(resourceFormat, request, null);
226  }
227
228  /**
229   * @param resourceFormat
230   * @param options
231   * @return
232   */
233  protected <T extends Resource> ResourceRequest<T> issueResourceRequest(String resourceFormat, HttpUriRequest request, byte[] payload) {
234    return issueResourceRequest(resourceFormat, request, payload, null);
235  }
236
237  /**
238   * @param resourceFormat
239   * @param options
240   * @return
241   */
242  protected <T extends Resource> ResourceRequest<T> issueResourceRequest(String resourceFormat, HttpUriRequest request, byte[] payload, List<Header> headers) {
243    configureFhirRequest(request, resourceFormat, headers);
244    HttpResponse response = null;
245    if(request instanceof HttpEntityEnclosingRequest && payload != null) {
246      response = sendPayload((HttpEntityEnclosingRequestBase)request, payload, proxy);
247    } else if (request instanceof HttpEntityEnclosingRequest && payload == null){
248      throw new EFhirClientException("PUT and POST requests require a non-null payload");
249    } else {
250      response = sendRequest(request);
251    }
252    T resource = unmarshalReference(response, resourceFormat);
253    return new ResourceRequest<T>(resource, response.getStatusLine().getStatusCode(), getLocationHeader(response));
254  }
255
256
257  /**
258   * Method adds required request headers.
259   * TODO handle JSON request as well.
260   * 
261   * @param request
262   */
263  protected void configureFhirRequest(HttpRequest request, String format) {
264    configureFhirRequest(request, format, null);
265  }
266
267  /**
268   * Method adds required request headers.
269   * TODO handle JSON request as well.
270   * 
271   * @param request
272   */
273  protected void configureFhirRequest(HttpRequest request, String format, List<Header> headers) {
274    request.addHeader("User-Agent", "Java FHIR Client for FHIR");
275
276    if (format != null) {               
277      request.addHeader("Accept",format);
278      request.addHeader("Content-Type", format + ";charset=" + DEFAULT_CHARSET);
279    }
280    request.addHeader("Accept-Charset", DEFAULT_CHARSET);
281    if(headers != null) {
282      for(Header header : headers) {
283        request.addHeader(header);
284      }
285    }
286    setAuth(request);
287  }
288
289  /**
290   * Method posts request payload
291   * 
292   * @param request
293   * @param payload
294   * @return
295   */
296  protected HttpResponse sendPayload(HttpEntityEnclosingRequestBase request, byte[] payload, HttpHost proxy) {
297    HttpResponse response = null;
298    try {
299      HttpClient httpclient = new DefaultHttpClient();
300      if(proxy != null) {
301        httpclient.getParams().setParameter(ConnRoutePNames.DEFAULT_PROXY, proxy);
302      }
303      request.setEntity(new ByteArrayEntity(payload));
304      log(request);
305      response = httpclient.execute(request);
306    } catch(IOException ioe) {
307      throw new EFhirClientException("Error sending HTTP Post/Put Payload", ioe);
308    }
309    return response;
310  }
311
312  /**
313   * 
314   * @param request
315   * @param payload
316   * @return
317   */
318  protected HttpResponse sendRequest(HttpUriRequest request) {
319    HttpResponse response = null;
320    try {
321      HttpClient httpclient = new DefaultHttpClient();
322      log(request);
323      HttpParams params = httpclient.getParams();
324      HttpConnectionParams.setConnectionTimeout(params, timeout);
325      HttpConnectionParams.setSoTimeout(params, timeout);
326      if(proxy != null) {
327        httpclient.getParams().setParameter(ConnRoutePNames.DEFAULT_PROXY, proxy);
328      }
329      response = httpclient.execute(request);
330    } catch(IOException ioe) {
331      throw new EFhirClientException("Error sending Http Request: "+ioe.getMessage(), ioe);
332    }
333    return response;
334  }
335
336
337  /**
338   * Unmarshals a resource from the response stream.
339   * 
340   * @param response
341   * @return
342   */
343  @SuppressWarnings("unchecked")
344  protected <T extends Resource> T unmarshalReference(HttpResponse response, String format) {
345    T resource = null;
346    OperationOutcome error = null;
347    byte[] cnt = log(response);
348    if (cnt != null) {
349      try {
350        resource = (T)getParser(format).parse(cnt);
351        if (resource instanceof OperationOutcome && hasError((OperationOutcome)resource)) {
352          error = (OperationOutcome) resource;
353        }
354      } catch(IOException ioe) {
355        throw new EFhirClientException("Error reading Http Response: "+ioe.getMessage(), ioe);
356      } catch(Exception e) {
357        throw new EFhirClientException("Error parsing response message: "+e.getMessage(), e);
358      }
359    }
360    if(error != null) {
361      throw new EFhirClientException("Error from server: "+ResourceUtilities.getErrorDescription(error), error);
362    }
363    return resource;
364  }
365
366  /**
367   * Unmarshals Bundle from response stream.
368   * 
369   * @param response
370   * @return
371   */
372  protected Bundle unmarshalFeed(HttpResponse response, String format) {
373    Bundle feed = null;
374    byte[] cnt = log(response);
375    String contentType = response.getHeaders("Content-Type")[0].getValue();
376    OperationOutcome error = null;
377    try {
378      if (cnt != null) {
379        if(contentType.contains(ResourceFormat.RESOURCE_XML.getHeader()) || contentType.contains("text/xml+fhir")) {
380          Resource rf = getParser(format).parse(cnt);
381          if (rf instanceof Bundle)
382            feed = (Bundle) rf;
383          else if (rf instanceof OperationOutcome && hasError((OperationOutcome) rf)) {
384            error = (OperationOutcome) rf;
385          } else {
386            throw new EFhirClientException("Error reading server response: a resource was returned instead");
387          }
388        }
389      }
390    } catch(IOException ioe) {
391      throw new EFhirClientException("Error reading Http Response", ioe);
392    } catch(Exception e) {
393      throw new EFhirClientException("Error parsing response message", e);
394    }
395    if(error != null) {
396      throw new EFhirClientException("Error from server: "+ResourceUtilities.getErrorDescription(error), error);
397    }
398    return feed;
399  }
400
401  private boolean hasError(OperationOutcome oo) {
402    for (OperationOutcomeIssueComponent t : oo.getIssue())
403      if (t.getSeverity() == IssueSeverity.ERROR || t.getSeverity() == IssueSeverity.FATAL)
404        return true;
405    return false;
406  }
407
408  protected String getLocationHeader(HttpResponse response) {
409    String location = null;
410    if(response.getHeaders("location").length > 0) {//TODO Distinguish between both cases if necessary
411      location = response.getHeaders("location")[0].getValue();
412    } else if(response.getHeaders("content-location").length > 0) {
413      location = response.getHeaders("content-location")[0].getValue();
414    }
415    return location;
416  }
417
418
419  /*****************************************************************
420   * Client connection methods
421   * ***************************************************************/
422
423  public HttpURLConnection buildConnection(URI baseServiceUri, String tail) {
424    try {
425      HttpURLConnection client = (HttpURLConnection) baseServiceUri.resolve(tail).toURL().openConnection();
426      return client;
427    } catch(MalformedURLException mue) {
428      throw new EFhirClientException("Invalid Service URL", mue);
429    } catch(IOException ioe) {
430      throw new EFhirClientException("Unable to establish connection to server: " + baseServiceUri.toString() + tail, ioe);
431    }
432  }
433
434  public HttpURLConnection buildConnection(URI baseServiceUri, ResourceType resourceType, String id) {
435    return buildConnection(baseServiceUri, ResourceAddress.buildRelativePathFromResourceType(resourceType, id));
436  }
437
438  /******************************************************************
439   * Other general helper methods
440   * ****************************************************************/
441
442
443  public  <T extends Resource>  byte[] getResourceAsByteArray(T resource, boolean pretty, boolean isJson) {
444    ByteArrayOutputStream baos = null;
445    byte[] byteArray = null;
446    try {
447      baos = new ByteArrayOutputStream();
448      IParser parser = null;
449      if(isJson) {
450        parser = new JsonParser();
451      } else {
452        parser = new XmlParser();
453      }
454      parser.setOutputStyle(pretty ? OutputStyle.PRETTY : OutputStyle.NORMAL);
455      parser.compose(baos, resource);
456      baos.close();
457      byteArray =  baos.toByteArray();
458      baos.close();
459    } catch (Exception e) {
460      try{
461        baos.close();
462      }catch(Exception ex) {
463        throw new EFhirClientException("Error closing output stream", ex);
464      }
465      throw new EFhirClientException("Error converting output stream to byte array", e);
466    }
467    return byteArray;
468  }
469
470  public  byte[] getFeedAsByteArray(Bundle feed, boolean pretty, boolean isJson) {
471    ByteArrayOutputStream baos = null;
472    byte[] byteArray = null;
473    try {
474      baos = new ByteArrayOutputStream();
475      IParser parser = null;
476      if(isJson) {
477        parser = new JsonParser();
478      } else {
479        parser = new XmlParser();
480      }
481      parser.setOutputStyle(pretty ? OutputStyle.PRETTY : OutputStyle.NORMAL);
482      parser.compose(baos, feed);
483      baos.close();
484      byteArray =  baos.toByteArray();
485      baos.close();
486    } catch (Exception e) {
487      try{
488        baos.close();
489      }catch(Exception ex) {
490        throw new EFhirClientException("Error closing output stream", ex);
491      }
492      throw new EFhirClientException("Error converting output stream to byte array", e);
493    }
494    return byteArray;
495  }
496
497  public Calendar getLastModifiedResponseHeaderAsCalendarObject(URLConnection serverConnection) {
498    String dateTime = null;
499    try {
500      dateTime = serverConnection.getHeaderField("Last-Modified");
501      SimpleDateFormat format = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz");
502      Date lastModifiedTimestamp = format.parse(dateTime);
503      Calendar calendar=Calendar.getInstance();
504      calendar.setTime(lastModifiedTimestamp);
505      return calendar;
506    } catch(ParseException pe) {
507      throw new EFhirClientException("Error parsing Last-Modified response header " + dateTime, pe);
508    }
509  }
510
511  protected IParser getParser(String format) {
512    if(StringUtils.isBlank(format)) {
513      format = ResourceFormat.RESOURCE_XML.getHeader();
514    }
515    if(format.equalsIgnoreCase("json") || format.equalsIgnoreCase(ResourceFormat.RESOURCE_JSON.getHeader()) || format.equalsIgnoreCase(ResourceFormat.RESOURCE_JSON.getHeader())) {
516      return new JsonParser();
517    } else if(format.equalsIgnoreCase("xml") || format.equalsIgnoreCase(ResourceFormat.RESOURCE_XML.getHeader()) || format.equalsIgnoreCase(ResourceFormat.RESOURCE_XML.getHeader())) {
518      return new XmlParser();
519    } else {
520      throw new EFhirClientException("Invalid format: " + format);
521    }
522  }
523
524  public Bundle issuePostFeedRequest(URI resourceUri, Map<String, String> parameters, String resourceName, Resource resource, String resourceFormat) throws IOException {
525    HttpPost httppost = new HttpPost(resourceUri);
526    String boundary = "----WebKitFormBoundarykbMUo6H8QaUnYtRy";
527    httppost.addHeader("Content-Type", "multipart/form-data; boundary="+boundary);
528    httppost.addHeader("Accept", resourceFormat);
529    configureFhirRequest(httppost, null);
530    HttpResponse response = sendPayload(httppost, encodeFormSubmission(parameters, resourceName, resource, boundary));
531    return unmarshalFeed(response, resourceFormat);
532  }
533
534  private byte[] encodeFormSubmission(Map<String, String> parameters, String resourceName, Resource resource, String boundary) throws IOException {
535    ByteArrayOutputStream b = new ByteArrayOutputStream();
536    OutputStreamWriter w = new OutputStreamWriter(b, "UTF-8");  
537    for (String name : parameters.keySet()) {
538      w.write("--");
539      w.write(boundary);
540      w.write("\r\nContent-Disposition: form-data; name=\""+name+"\"\r\n\r\n");
541      w.write(parameters.get(name)+"\r\n");
542    }
543    w.write("--");
544    w.write(boundary);
545    w.write("\r\nContent-Disposition: form-data; name=\""+resourceName+"\"\r\n\r\n");
546    w.close(); 
547    JsonParser json = new JsonParser();
548    json.setOutputStyle(OutputStyle.NORMAL);
549    json.compose(b, resource);
550    b.close();
551    w = new OutputStreamWriter(b, "UTF-8");  
552    w.write("\r\n--");
553    w.write(boundary);
554    w.write("--");
555    w.close();
556    return b.toByteArray();
557  }
558
559  /**
560   * Method posts request payload
561   * 
562   * @param request
563   * @param payload
564   * @return
565   */
566  protected HttpResponse sendPayload(HttpEntityEnclosingRequestBase request, byte[] payload) {
567    HttpResponse response = null;
568    try {
569      log(request);
570      HttpClient httpclient = new DefaultHttpClient();
571      request.setEntity(new ByteArrayEntity(payload));
572      response = httpclient.execute(request);
573      log(response);
574    } catch(IOException ioe) {
575      throw new EFhirClientException("Error sending HTTP Post/Put Payload: "+ioe.getMessage(), ioe);
576    }
577    return response;
578  }
579
580  private void log(HttpUriRequest request) {
581    if (logger != null) {
582      List<String> headers = new ArrayList<>();
583      for (Header h : request.getAllHeaders()) {
584        headers.add(h.toString());
585      }
586      logger.logRequest(request.getMethod(), request.getURI().toString(), headers, null);
587    }    
588  }
589  private void log(HttpEntityEnclosingRequestBase request)  {
590    if (logger != null) {
591      List<String> headers = new ArrayList<>();
592      for (Header h : request.getAllHeaders()) {
593        headers.add(h.toString());
594      }
595      byte[] cnt = null;
596      InputStream s;
597      try {
598        s = request.getEntity().getContent();
599        cnt = IOUtils.toByteArray(s);
600        s.close();
601      } catch (Exception e) {
602      }
603      logger.logRequest(request.getMethod(), request.getURI().toString(), headers, cnt);
604    }    
605  }  
606  
607  private byte[] log(HttpResponse response) {
608    byte[] cnt = null;
609    try {
610      InputStream s = response.getEntity().getContent();
611      cnt = IOUtils.toByteArray(s);
612      s.close();
613    } catch (Exception e) {
614    }
615    if (logger != null) {
616      List<String> headers = new ArrayList<>();
617      for (Header h : response.getAllHeaders()) {
618        headers.add(h.toString());
619      }
620      logger.logResponse(response.getStatusLine().toString(), headers, cnt);
621    }
622    return cnt;
623  }
624
625  public ToolingClientLogger getLogger() {
626    return logger;
627  }
628
629  public void setLogger(ToolingClientLogger logger) {
630    this.logger = logger;
631  }
632
633  
634  /**
635   * Used for debugging
636   * 
637   * @param instream
638   * @return
639   */
640  protected String writeInputStreamAsString(InputStream instream) {
641    String value = null;
642    try {
643      value = IOUtils.toString(instream, "UTF-8");
644      System.out.println(value);
645
646    } catch(IOException ioe) {
647      //Do nothing
648    }
649    return value;
650  }
651
652
653}