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