Merge branch 'releases/Release_2_10_4_Branch' 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, EnsemblData> 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(DEFAULT_ENSEMBL_BASEURL,
90             new EnsemblData(DEFAULT_ENSEMBL_BASEURL, LATEST_ENSEMBL_REST_VERSION));
91     domainData.put(DEFAULT_ENSEMBL_GENOMES_BASEURL, new EnsemblData(
92             DEFAULT_ENSEMBL_GENOMES_BASEURL, 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     super();
103
104     /*
105      * initialise domain info lazily
106      */
107     if (!domainData.containsKey(ensemblDomain))
108     {
109       domainData.put(ensemblDomain,
110               new EnsemblData(ensemblDomain, LATEST_ENSEMBL_REST_VERSION));
111     }
112     if (!domainData.containsKey(ensemblGenomesDomain))
113     {
114       domainData.put(ensemblGenomesDomain, new EnsemblData(
115               ensemblGenomesDomain, LATEST_ENSEMBLGENOMES_REST_VERSION));
116     }
117   }
118
119   /**
120    * Constructor given the target domain to fetch data from
121    * 
122    * @param d
123    */
124   public EnsemblRestClient(String d)
125   {
126     setDomain(d);
127   }
128
129   @Override
130   public boolean queryInProgress()
131   {
132     return inProgress;
133   }
134
135   @Override
136   public StringBuffer getRawRecords()
137   {
138     return null;
139   }
140
141   /**
142    * Returns the URL for the client http request
143    * 
144    * @param ids
145    * @return
146    * @throws MalformedURLException
147    */
148   protected abstract URL getUrl(List<String> ids)
149           throws MalformedURLException;
150
151   /**
152    * Returns true if client uses GET method, false if it uses POST
153    * 
154    * @return
155    */
156   protected abstract boolean useGetRequest();
157
158   /**
159    * Return the desired value for the Content-Type request header
160    * 
161    * @param multipleIds
162    * 
163    * @return
164    * @see https://github.com/Ensembl/ensembl-rest/wiki/HTTP-Headers
165    */
166   protected abstract String getRequestMimeType(boolean multipleIds);
167
168   /**
169    * Return the desired value for the Accept request header
170    * 
171    * @return
172    * @see https://github.com/Ensembl/ensembl-rest/wiki/HTTP-Headers
173    */
174   protected abstract String getResponseMimeType();
175
176   /**
177    * Checks Ensembl's REST 'ping' endpoint, and returns true if response
178    * indicates available, else false
179    * 
180    * @see http://rest.ensembl.org/documentation/info/ping
181    * @return
182    */
183   boolean checkEnsembl()
184   {
185     BufferedReader br = null;
186     String pingUrl = getDomain() + "/info/ping" + CONTENT_TYPE_JSON;
187     try
188     {
189       // note this format works for both ensembl and ensemblgenomes
190       // info/ping.json works for ensembl only (March 2016)
191       URL ping = new URL(pingUrl);
192
193       /*
194        * expect {"ping":1} if ok
195        * if ping takes more than 2 seconds to respond, treat as if unavailable
196        */
197       br = getHttpResponse(ping, null, 2 * 1000);
198       if (br == null)
199       {
200         // error reponse status
201         return false;
202       }
203       JSONParser jp = new JSONParser();
204       JSONObject val = (JSONObject) jp.parse(br);
205       String pingString = val.get("ping").toString();
206       return pingString != null;
207     } catch (Throwable t)
208     {
209       System.err.println(
210               "Error connecting to " + pingUrl + ": " + t.getMessage());
211     } finally
212     {
213       if (br != null)
214       {
215         try
216         {
217           br.close();
218         } catch (IOException e)
219         {
220           // ignore
221         }
222       }
223     }
224     return false;
225   }
226
227   /**
228    * returns a reader to a Fasta response from the Ensembl sequence endpoint
229    * 
230    * @param ids
231    * @return
232    * @throws IOException
233    */
234   protected FileParse getSequenceReader(List<String> ids) throws IOException
235   {
236     URL url = getUrl(ids);
237
238     BufferedReader reader = getHttpResponse(url, ids);
239     if (reader == null)
240     {
241       // request failed
242       return null;
243     }
244     FileParse fp = new FileParse(reader, url.toString(),
245             DataSourceType.URL);
246     return fp;
247   }
248
249   /**
250    * Gets a reader to the HTTP response, using the default read timeout of 5
251    * minutes
252    * 
253    * @param url
254    * @param ids
255    * @return
256    * @throws IOException
257    */
258   protected BufferedReader getHttpResponse(URL url, List<String> ids)
259           throws IOException
260   {
261     return getHttpResponse(url, ids, DEFAULT_READ_TIMEOUT);
262   }
263
264   /**
265    * Sends the HTTP request and gets the response as a reader
266    * 
267    * @param url
268    * @param ids
269    *          written as Json POST body if more than one
270    * @param readTimeout
271    *          in milliseconds
272    * @return
273    * @throws IOException
274    *           if response code was not 200, or other I/O error
275    */
276   protected BufferedReader getHttpResponse(URL url, List<String> ids,
277           int readTimeout) throws IOException
278   {
279     int retriesLeft = MAX_RETRIES;
280     HttpURLConnection connection = null;
281     int responseCode = 0;
282
283     while (retriesLeft > 0)
284     {
285       connection = tryConnection(url, ids, readTimeout);
286       responseCode = connection.getResponseCode();
287       if (responseCode == HTTP_OVERLOAD) // 429
288       {
289         retriesLeft--;
290         checkRetryAfter(connection);
291       }
292       else
293       {
294         retriesLeft = 0;
295       }
296     }
297     if (responseCode != HTTP_OK) // 200
298     {
299       /*
300        * note: a GET request for an invalid id returns an error code e.g. 415
301        * but POST request returns 200 and an empty Fasta response 
302        */
303       System.err.println("Response code " + responseCode + " for " + url);
304       return null;
305     }
306
307     InputStream response = connection.getInputStream();
308
309     // System.out.println(getClass().getName() + " took "
310     // + (System.currentTimeMillis() - now) + "ms to fetch");
311
312     BufferedReader reader = null;
313     reader = new BufferedReader(new InputStreamReader(response, "UTF-8"));
314     return reader;
315   }
316
317   /**
318    * @param url
319    * @param ids
320    * @param readTimeout
321    * @return
322    * @throws IOException
323    * @throws ProtocolException
324    */
325   protected HttpURLConnection tryConnection(URL url, List<String> ids,
326           int readTimeout) throws IOException, ProtocolException
327   {
328     // System.out.println(System.currentTimeMillis() + " " + url);
329     HttpURLConnection connection = (HttpURLConnection) url.openConnection();
330
331     /*
332      * POST method allows multiple queries in one request; it is supported for
333      * sequence queries, but not for overlap
334      */
335     boolean multipleIds = ids != null && ids.size() > 1;
336     connection.setRequestMethod(
337             multipleIds ? HttpMethod.POST : HttpMethod.GET);
338     connection.setRequestProperty("Content-Type",
339             getRequestMimeType(multipleIds));
340     connection.setRequestProperty("Accept", getResponseMimeType());
341
342     connection.setUseCaches(false);
343     connection.setDoInput(true);
344     connection.setDoOutput(multipleIds);
345
346     connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
347     connection.setReadTimeout(readTimeout);
348
349     if (multipleIds)
350     {
351       writePostBody(connection, ids);
352     }
353     return connection;
354   }
355
356   /**
357    * Inspects response headers for a 'retry-after' directive, and waits for the
358    * directed period (if less than 10 seconds)
359    * 
360    * @see https://github.com/Ensembl/ensembl-rest/wiki/Rate-Limits
361    * @param connection
362    */
363   void checkRetryAfter(HttpURLConnection connection)
364   {
365     String retryDelay = connection.getHeaderField("Retry-After");
366
367     // to test:
368     // retryDelay = "5";
369
370     if (retryDelay != null)
371     {
372       try
373       {
374         int retrySecs = Integer.valueOf(retryDelay);
375         if (retrySecs > 0 && retrySecs < 10)
376         {
377           System.err
378                   .println("Ensembl REST service rate limit exceeded, waiting "
379                           + retryDelay + " seconds before retrying");
380           Thread.sleep(1000 * retrySecs);
381         }
382       } catch (NumberFormatException | InterruptedException e)
383       {
384         System.err.println("Error handling Retry-After: " + e.getMessage());
385       }
386     }
387   }
388
389   /**
390    * Rechecks if Ensembl is responding, unless the last check was successful and
391    * the retest interval has not yet elapsed. Returns true if Ensembl is up,
392    * else false. Also retrieves and saves the current version of Ensembl data
393    * and REST services at intervals.
394    * 
395    * @return
396    */
397   protected boolean isEnsemblAvailable()
398   {
399     EnsemblData info = domainData.get(getDomain());
400
401     long now = System.currentTimeMillis();
402
403     /*
404      * recheck if Ensembl is up if it was down, or the recheck period has elapsed
405      */
406     boolean retestAvailability = (now
407             - info.lastAvailableCheckTime) > AVAILABILITY_RETEST_INTERVAL;
408     if (!info.restAvailable || retestAvailability)
409     {
410       info.restAvailable = checkEnsembl();
411       info.lastAvailableCheckTime = now;
412     }
413
414     /*
415      * refetch Ensembl versions if the recheck period has elapsed
416      */
417     boolean refetchVersion = (now
418             - info.lastVersionCheckTime) > VERSION_RETEST_INTERVAL;
419     if (refetchVersion)
420     {
421       checkEnsemblRestVersion();
422       checkEnsemblDataVersion();
423       info.lastVersionCheckTime = now;
424     }
425
426     return info.restAvailable;
427   }
428
429   /**
430    * Constructs, writes and flushes the POST body of the request, containing the
431    * query ids in JSON format
432    * 
433    * @param connection
434    * @param ids
435    * @throws IOException
436    */
437   protected void writePostBody(HttpURLConnection connection,
438           List<String> ids) throws IOException
439   {
440     boolean first;
441     StringBuilder postBody = new StringBuilder(64);
442     postBody.append("{\"ids\":[");
443     first = true;
444     for (String id : ids)
445     {
446       if (!first)
447       {
448         postBody.append(",");
449       }
450       first = false;
451       postBody.append("\"");
452       postBody.append(id.trim());
453       postBody.append("\"");
454     }
455     postBody.append("]}");
456     byte[] thepostbody = postBody.toString().getBytes();
457     connection.setRequestProperty("Content-Length",
458             Integer.toString(thepostbody.length));
459     DataOutputStream wr = new DataOutputStream(
460             connection.getOutputStream());
461     wr.write(thepostbody);
462     wr.flush();
463     wr.close();
464   }
465
466   /**
467    * Fetches and checks Ensembl's REST version number
468    * 
469    * @return
470    */
471   private void checkEnsemblRestVersion()
472   {
473     EnsemblData info = domainData.get(getDomain());
474
475     JSONParser jp = new JSONParser();
476     URL url = null;
477     try
478     {
479       url = new URL(getDomain() + "/info/rest" + CONTENT_TYPE_JSON);
480       BufferedReader br = getHttpResponse(url, null);
481       if (br == null)
482       {
483         return;
484       }
485       JSONObject val = (JSONObject) jp.parse(br);
486       String version = val.get("release").toString();
487       String majorVersion = version.substring(0, version.indexOf("."));
488       String expected = info.expectedRestVersion;
489       String expectedMajorVersion = expected.substring(0,
490               expected.indexOf("."));
491       info.restMajorVersionMismatch = false;
492       try
493       {
494         /*
495          * if actual REST major version is ahead of what we expect,
496          * record this in case we want to warn the user
497          */
498         if (Float.valueOf(majorVersion) > Float
499                 .valueOf(expectedMajorVersion))
500         {
501           info.restMajorVersionMismatch = true;
502         }
503       } catch (NumberFormatException e)
504       {
505         System.err.println("Error in REST version: " + e.toString());
506       }
507
508       /*
509        * check if REST version is later than what Jalview has tested against,
510        * if so warn; we don't worry if it is earlier (this indicates Jalview has
511        * been tested in advance against the next pending REST version)
512        */
513       boolean laterVersion = StringUtils.compareVersions(version,
514               expected) == 1;
515       if (laterVersion)
516       {
517         System.err.println(String.format(
518                 "EnsemblRestClient expected %s REST version %s but found %s, see %s",
519                 getDbSource(), expected, version, REST_CHANGE_LOG));
520       }
521       info.restVersion = version;
522     } catch (Throwable t)
523     {
524       System.err.println(
525               "Error checking Ensembl REST version: " + t.getMessage());
526     }
527   }
528
529   public boolean isRestMajorVersionMismatch()
530   {
531     return domainData.get(getDomain()).restMajorVersionMismatch;
532   }
533
534   /**
535    * Fetches and checks Ensembl's data version number
536    * 
537    * @return
538    */
539   private void checkEnsemblDataVersion()
540   {
541     JSONParser jp = new JSONParser();
542     URL url = null;
543     BufferedReader br = null;
544
545     try
546     {
547       url = new URL(getDomain() + "/info/data" + CONTENT_TYPE_JSON);
548       br = getHttpResponse(url, null);
549       if (br != null)
550       {
551         JSONObject val = (JSONObject) jp.parse(br);
552         JSONArray versions = (JSONArray) val.get("releases");
553         domainData.get(getDomain()).dataVersion = versions.get(0)
554                 .toString();
555       }
556     } catch (Throwable t)
557     {
558       System.err.println(
559               "Error checking Ensembl data version: " + t.getMessage());
560     } finally
561     {
562       if (br != null)
563       {
564         try
565         {
566           br.close();
567         } catch (IOException e)
568         {
569           // ignore
570         }
571       }
572     }
573   }
574
575   public String getEnsemblDataVersion()
576   {
577     return domainData.get(getDomain()).dataVersion;
578   }
579
580   @Override
581   public String getDbVersion()
582   {
583     return getEnsemblDataVersion();
584   }
585
586 }