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