Merge branch 'bug/JAL-2253' into develop
[jalview.git] / src / jalview / ext / ensembl / EnsemblRestClient.java
1 /*
2  * Jalview - A Sequence Alignment Editor and Viewer ($$Version-Rel$$)
3  * Copyright (C) $$Year-Rel$$ The Jalview Authors
4  * 
5  * This file is part of Jalview.
6  * 
7  * Jalview is free software: you can redistribute it and/or
8  * modify it under the terms of the GNU General Public License 
9  * as published by the Free Software Foundation, either version 3
10  * of the License, or (at your option) any later version.
11  *  
12  * Jalview is distributed in the hope that it will be useful, but 
13  * WITHOUT ANY WARRANTY; without even the implied warranty 
14  * of MERCHANTABILITY or FITNESS FOR A PARTICULAR 
15  * PURPOSE.  See the GNU General Public License for more details.
16  * 
17  * You should have received a copy of the GNU General Public License
18  * along with Jalview.  If not, see <http://www.gnu.org/licenses/>.
19  * The Jalview Authors are detailed in the 'AUTHORS' file.
20  */
21 package jalview.ext.ensembl;
22
23 import jalview.io.DataSourceType;
24 import jalview.io.FileParse;
25 import jalview.util.StringUtils;
26
27 import java.io.BufferedReader;
28 import java.io.DataOutputStream;
29 import java.io.IOException;
30 import java.io.InputStream;
31 import java.io.InputStreamReader;
32 import java.net.HttpURLConnection;
33 import java.net.MalformedURLException;
34 import java.net.ProtocolException;
35 import java.net.URL;
36 import java.util.HashMap;
37 import java.util.List;
38 import java.util.Map;
39
40 import javax.ws.rs.HttpMethod;
41
42 import org.json.simple.JSONArray;
43 import org.json.simple.JSONObject;
44 import org.json.simple.parser.JSONParser;
45
46 /**
47  * Base class for Ensembl REST service clients
48  * 
49  * @author gmcarstairs
50  */
51 abstract class EnsemblRestClient extends EnsemblSequenceFetcher
52 {
53   private static final int DEFAULT_READ_TIMEOUT = 5 * 60 * 1000; // 5 minutes
54
55   private static final int CONNECT_TIMEOUT_MS = 10 * 1000; // 10 seconds
56
57   private static final int MAX_RETRIES = 3;
58
59   private static final int HTTP_OK = 200;
60
61   private static final int HTTP_OVERLOAD = 429;
62
63   /*
64    * update these constants when Jalview has been checked / updated for
65    * changes to Ensembl REST API (ref JAL-2105)
66    * @see https://github.com/Ensembl/ensembl-rest/wiki/Change-log
67    * @see http://rest.ensembl.org/info/rest?content-type=application/json
68    */
69   private static final String LATEST_ENSEMBLGENOMES_REST_VERSION = "6.0";
70
71   private static final String LATEST_ENSEMBL_REST_VERSION = "6.1";
72
73   private static final String REST_CHANGE_LOG = "https://github.com/Ensembl/ensembl-rest/wiki/Change-log";
74
75   private static Map<String, EnsemblInfo> domainData;
76
77   // @see https://github.com/Ensembl/ensembl-rest/wiki/Output-formats
78   private static final String PING_URL = "http://rest.ensembl.org/info/ping.json";
79
80   private final static long AVAILABILITY_RETEST_INTERVAL = 10000L; // 10 seconds
81
82   private final static long VERSION_RETEST_INTERVAL = 1000L * 3600; // 1 hr
83
84   protected static final String CONTENT_TYPE_JSON = "?content-type=application/json";
85
86   static
87   {
88     domainData = new HashMap<>();
89     domainData.put(ENSEMBL_REST,
90             new EnsemblInfo(ENSEMBL_REST, LATEST_ENSEMBL_REST_VERSION));
91     domainData.put(ENSEMBL_GENOMES_REST, new EnsemblInfo(
92             ENSEMBL_GENOMES_REST, LATEST_ENSEMBLGENOMES_REST_VERSION));
93   }
94
95   protected volatile boolean inProgress = false;
96
97   /**
98    * Default constructor to use rest.ensembl.org
99    */
100   public EnsemblRestClient()
101   {
102     this(ENSEMBL_REST);
103   }
104
105   /**
106    * Constructor given the target domain to fetch data from
107    * 
108    * @param d
109    */
110   public EnsemblRestClient(String d)
111   {
112     setDomain(d);
113   }
114
115   @Override
116   public boolean queryInProgress()
117   {
118     return inProgress;
119   }
120
121   @Override
122   public StringBuffer getRawRecords()
123   {
124     return null;
125   }
126
127   /**
128    * Returns the URL for the client http request
129    * 
130    * @param ids
131    * @return
132    * @throws MalformedURLException
133    */
134   protected abstract URL getUrl(List<String> ids)
135           throws MalformedURLException;
136
137   /**
138    * Returns true if client uses GET method, false if it uses POST
139    * 
140    * @return
141    */
142   protected abstract boolean useGetRequest();
143
144   /**
145    * Return the desired value for the Content-Type request header
146    * 
147    * @param multipleIds
148    * 
149    * @return
150    * @see https://github.com/Ensembl/ensembl-rest/wiki/HTTP-Headers
151    */
152   protected abstract String getRequestMimeType(boolean multipleIds);
153
154   /**
155    * Return the desired value for the Accept request header
156    * 
157    * @return
158    * @see https://github.com/Ensembl/ensembl-rest/wiki/HTTP-Headers
159    */
160   protected abstract String getResponseMimeType();
161
162   /**
163    * Checks Ensembl's REST 'ping' endpoint, and returns true if response
164    * indicates available, else false
165    * 
166    * @see http://rest.ensembl.org/documentation/info/ping
167    * @return
168    */
169   boolean checkEnsembl()
170   {
171     BufferedReader br = null;
172     try
173     {
174       // note this format works for both ensembl and ensemblgenomes
175       // info/ping.json works for ensembl only (March 2016)
176       URL ping = new URL(getDomain() + "/info/ping" + CONTENT_TYPE_JSON);
177
178       /*
179        * expect {"ping":1} if ok
180        * if ping takes more than 2 seconds to respond, treat as if unavailable
181        */
182       br = getHttpResponse(ping, null, 2 * 1000);
183       if (br == null)
184       {
185         // error reponse status
186         return false;
187       }
188       JSONParser jp = new JSONParser();
189       JSONObject val = (JSONObject) jp.parse(br);
190       String pingString = val.get("ping").toString();
191       return pingString != null;
192     } catch (Throwable t)
193     {
194       System.err.println(
195               "Error connecting to " + PING_URL + ": " + t.getMessage());
196     } finally
197     {
198       if (br != null)
199       {
200         try
201         {
202           br.close();
203         } catch (IOException e)
204         {
205           // ignore
206         }
207       }
208     }
209     return false;
210   }
211
212   /**
213    * returns a reader to a Fasta response from the Ensembl sequence endpoint
214    * 
215    * @param ids
216    * @return
217    * @throws IOException
218    */
219   protected FileParse getSequenceReader(List<String> ids) throws IOException
220   {
221     URL url = getUrl(ids);
222
223     BufferedReader reader = getHttpResponse(url, ids);
224     if (reader == null)
225     {
226       // request failed
227       return null;
228     }
229     FileParse fp = new FileParse(reader, url.toString(),
230             DataSourceType.URL);
231     return fp;
232   }
233
234   /**
235    * Gets a reader to the HTTP response, using the default read timeout of 5
236    * minutes
237    * 
238    * @param url
239    * @param ids
240    * @return
241    * @throws IOException
242    */
243   protected BufferedReader getHttpResponse(URL url, List<String> ids)
244           throws IOException
245   {
246     return getHttpResponse(url, ids, DEFAULT_READ_TIMEOUT);
247   }
248
249   /**
250    * Sends the HTTP request and gets the response as a reader
251    * 
252    * @param url
253    * @param ids
254    *          written as Json POST body if more than one
255    * @param readTimeout
256    *          in milliseconds
257    * @return
258    * @throws IOException
259    *           if response code was not 200, or other I/O error
260    */
261   protected BufferedReader getHttpResponse(URL url, List<String> ids,
262           int readTimeout) throws IOException
263   {
264     int retriesLeft = MAX_RETRIES;
265     HttpURLConnection connection = null;
266     int responseCode = 0;
267
268     while (retriesLeft > 0)
269     {
270       connection = tryConnection(url, ids, readTimeout);
271       responseCode = connection.getResponseCode();
272       if (responseCode == HTTP_OVERLOAD) // 429
273       {
274         retriesLeft--;
275         checkRetryAfter(connection);
276       }
277       else
278       {
279         retriesLeft = 0;
280       }
281     }
282     if (responseCode != HTTP_OK) // 200
283     {
284       /*
285        * note: a GET request for an invalid id returns an error code e.g. 415
286        * but POST request returns 200 and an empty Fasta response 
287        */
288       System.err.println("Response code " + responseCode + " for " + url);
289       return null;
290     }
291
292     InputStream response = connection.getInputStream();
293
294     // System.out.println(getClass().getName() + " took "
295     // + (System.currentTimeMillis() - now) + "ms to fetch");
296
297     BufferedReader reader = null;
298     reader = new BufferedReader(new InputStreamReader(response, "UTF-8"));
299     return reader;
300   }
301
302   /**
303    * @param url
304    * @param ids
305    * @param readTimeout
306    * @return
307    * @throws IOException
308    * @throws ProtocolException
309    */
310   protected HttpURLConnection tryConnection(URL url, List<String> ids,
311           int readTimeout) throws IOException, ProtocolException
312   {
313     // System.out.println(System.currentTimeMillis() + " " + url);
314     HttpURLConnection connection = (HttpURLConnection) url.openConnection();
315
316     /*
317      * POST method allows multiple queries in one request; it is supported for
318      * sequence queries, but not for overlap
319      */
320     boolean multipleIds = ids != null && ids.size() > 1;
321     connection.setRequestMethod(
322             multipleIds ? HttpMethod.POST : HttpMethod.GET);
323     connection.setRequestProperty("Content-Type",
324             getRequestMimeType(multipleIds));
325     connection.setRequestProperty("Accept", getResponseMimeType());
326
327     connection.setUseCaches(false);
328     connection.setDoInput(true);
329     connection.setDoOutput(multipleIds);
330
331     connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
332     connection.setReadTimeout(readTimeout);
333
334     if (multipleIds)
335     {
336       writePostBody(connection, ids);
337     }
338     return connection;
339   }
340
341   /**
342    * Inspects response headers for a 'retry-after' directive, and waits for the
343    * directed period (if less than 10 seconds)
344    * 
345    * @see https://github.com/Ensembl/ensembl-rest/wiki/Rate-Limits
346    * @param connection
347    */
348   void checkRetryAfter(HttpURLConnection connection)
349   {
350     String retryDelay = connection.getHeaderField("Retry-After");
351
352     // to test:
353     // retryDelay = "5";
354
355     if (retryDelay != null)
356     {
357       try
358       {
359         int retrySecs = Integer.valueOf(retryDelay);
360         if (retrySecs > 0 && retrySecs < 10)
361         {
362           System.err
363                   .println("Ensembl REST service rate limit exceeded, waiting "
364                           + retryDelay + " seconds before retrying");
365           Thread.sleep(1000 * retrySecs);
366         }
367       } catch (NumberFormatException | InterruptedException e)
368       {
369         System.err.println("Error handling Retry-After: " + e.getMessage());
370       }
371     }
372   }
373
374   /**
375    * Rechecks if Ensembl is responding, unless the last check was successful and
376    * the retest interval has not yet elapsed. Returns true if Ensembl is up,
377    * else false. Also retrieves and saves the current version of Ensembl data
378    * and REST services at intervals.
379    * 
380    * @return
381    */
382   protected boolean isEnsemblAvailable()
383   {
384     EnsemblInfo info = domainData.get(getDomain());
385
386     long now = System.currentTimeMillis();
387
388     /*
389      * recheck if Ensembl is up if it was down, or the recheck period has elapsed
390      */
391     boolean retestAvailability = (now
392             - info.lastAvailableCheckTime) > AVAILABILITY_RETEST_INTERVAL;
393     if (!info.restAvailable || retestAvailability)
394     {
395       info.restAvailable = checkEnsembl();
396       info.lastAvailableCheckTime = now;
397     }
398
399     /*
400      * refetch Ensembl versions if the recheck period has elapsed
401      */
402     boolean refetchVersion = (now
403             - info.lastVersionCheckTime) > VERSION_RETEST_INTERVAL;
404     if (refetchVersion)
405     {
406       checkEnsemblRestVersion();
407       checkEnsemblDataVersion();
408       info.lastVersionCheckTime = now;
409     }
410
411     return info.restAvailable;
412   }
413
414   /**
415    * Constructs, writes and flushes the POST body of the request, containing the
416    * query ids in JSON format
417    * 
418    * @param connection
419    * @param ids
420    * @throws IOException
421    */
422   protected void writePostBody(HttpURLConnection connection,
423           List<String> ids) throws IOException
424   {
425     boolean first;
426     StringBuilder postBody = new StringBuilder(64);
427     postBody.append("{\"ids\":[");
428     first = true;
429     for (String id : ids)
430     {
431       if (!first)
432       {
433         postBody.append(",");
434       }
435       first = false;
436       postBody.append("\"");
437       postBody.append(id.trim());
438       postBody.append("\"");
439     }
440     postBody.append("]}");
441     byte[] thepostbody = postBody.toString().getBytes();
442     connection.setRequestProperty("Content-Length",
443             Integer.toString(thepostbody.length));
444     DataOutputStream wr = new DataOutputStream(
445             connection.getOutputStream());
446     wr.write(thepostbody);
447     wr.flush();
448     wr.close();
449   }
450
451   /**
452    * Fetches and checks Ensembl's REST version number
453    * 
454    * @return
455    */
456   private void checkEnsemblRestVersion()
457   {
458     EnsemblInfo info = domainData.get(getDomain());
459
460     JSONParser jp = new JSONParser();
461     URL url = null;
462     try
463     {
464       url = new URL(getDomain() + "/info/rest" + CONTENT_TYPE_JSON);
465       BufferedReader br = getHttpResponse(url, null);
466       if (br == null)
467       {
468         return;
469       }
470       JSONObject val = (JSONObject) jp.parse(br);
471       String version = val.get("release").toString();
472       String majorVersion = version.substring(0, version.indexOf("."));
473       String expected = info.expectedRestVersion;
474       String expectedMajorVersion = expected.substring(0,
475               expected.indexOf("."));
476       info.restMajorVersionMismatch = false;
477       try
478       {
479         /*
480          * if actual REST major version is ahead of what we expect,
481          * record this in case we want to warn the user
482          */
483         if (Float.valueOf(majorVersion) > Float
484                 .valueOf(expectedMajorVersion))
485         {
486           info.restMajorVersionMismatch = true;
487         }
488       } catch (NumberFormatException e)
489       {
490         System.err.println("Error in REST version: " + e.toString());
491       }
492
493       /*
494        * check if REST version is later than what Jalview has tested against,
495        * if so warn; we don't worry if it is earlier (this indicates Jalview has
496        * been tested in advance against the next pending REST version)
497        */
498       boolean laterVersion = StringUtils.compareVersions(version,
499               expected) == 1;
500       if (laterVersion)
501       {
502         System.err.println(String.format(
503                 "EnsemblRestClient expected %s REST version %s but found %s, see %s",
504                 getDbSource(), expected, version, REST_CHANGE_LOG));
505       }
506       info.restVersion = version;
507     } catch (Throwable t)
508     {
509       System.err.println(
510               "Error checking Ensembl REST version: " + t.getMessage());
511     }
512   }
513
514   public boolean isRestMajorVersionMismatch()
515   {
516     return domainData.get(getDomain()).restMajorVersionMismatch;
517   }
518
519   /**
520    * Fetches and checks Ensembl's data version number
521    * 
522    * @return
523    */
524   private void checkEnsemblDataVersion()
525   {
526     JSONParser jp = new JSONParser();
527     URL url = null;
528     BufferedReader br = null;
529
530     try
531     {
532       url = new URL(getDomain() + "/info/data" + CONTENT_TYPE_JSON);
533       br = getHttpResponse(url, null);
534       if (br != null)
535       {
536         JSONObject val = (JSONObject) jp.parse(br);
537         JSONArray versions = (JSONArray) val.get("releases");
538         domainData.get(getDomain()).dataVersion = versions.get(0)
539                 .toString();
540       }
541     } catch (Throwable t)
542     {
543       System.err.println(
544               "Error checking Ensembl data version: " + t.getMessage());
545     } finally
546     {
547       if (br != null)
548       {
549         try
550         {
551           br.close();
552         } catch (IOException e)
553         {
554           // ignore
555         }
556       }
557     }
558   }
559
560   public String getEnsemblDataVersion()
561   {
562     return domainData.get(getDomain()).dataVersion;
563   }
564
565   @Override
566   public String getDbVersion()
567   {
568     return getEnsemblDataVersion();
569   }
570
571 }