d4576683ad5f33a3fd7147bf4a965bcded001221
[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 = new HashMap<>();
76
77   private final static long AVAILABILITY_RETEST_INTERVAL = 10000L; // 10 seconds
78
79   private final static long VERSION_RETEST_INTERVAL = 1000L * 3600; // 1 hr
80
81   protected static final String CONTENT_TYPE_JSON = "?content-type=application/json";
82
83   static
84   {
85     domainData.put(DEFAULT_ENSEMBL_DOMAIN,
86             new EnsemblInfo(DEFAULT_ENSEMBL_DOMAIN, LATEST_ENSEMBL_REST_VERSION));
87     domainData.put(DEFAULT_ENSEMBL_GENOMES_DOMAIN, new EnsemblInfo(
88             DEFAULT_ENSEMBL_GENOMES_DOMAIN, 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 EnsemblInfo(ensemblDomain, LATEST_ENSEMBL_REST_VERSION));
107     }
108     if (!domainData.containsKey(ensemblGenomesDomain))
109     {
110       domainData.put(ensemblGenomesDomain, new EnsemblInfo(
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    * Return the desired value for the Content-Type request header
156    * 
157    * @param multipleIds
158    * 
159    * @return
160    * @see https://github.com/Ensembl/ensembl-rest/wiki/HTTP-Headers
161    */
162   protected abstract String getRequestMimeType(boolean multipleIds);
163
164   /**
165    * Return the desired value for the Accept request header
166    * 
167    * @return
168    * @see https://github.com/Ensembl/ensembl-rest/wiki/HTTP-Headers
169    */
170   protected abstract String getResponseMimeType();
171
172   /**
173    * Checks Ensembl's REST 'ping' endpoint, and returns true if response
174    * indicates available, else false
175    * 
176    * @see http://rest.ensembl.org/documentation/info/ping
177    * @return
178    */
179   boolean checkEnsembl()
180   {
181     BufferedReader br = null;
182     String pingUrl = getDomain() + "/info/ping" + CONTENT_TYPE_JSON;
183     try
184     {
185       // note this format works for both ensembl and ensemblgenomes
186       // info/ping.json works for ensembl only (March 2016)
187       URL ping = new URL(pingUrl);
188
189       /*
190        * expect {"ping":1} if ok
191        * if ping takes more than 2 seconds to respond, treat as if unavailable
192        */
193       br = getHttpResponse(ping, null, 2 * 1000);
194       if (br == null)
195       {
196         // error reponse status
197         return false;
198       }
199       JSONParser jp = new JSONParser();
200       JSONObject val = (JSONObject) jp.parse(br);
201       String pingString = val.get("ping").toString();
202       return pingString != null;
203     } catch (Throwable t)
204     {
205       System.err.println(
206               "Error connecting to " + pingUrl + ": " + t.getMessage());
207     } finally
208     {
209       if (br != null)
210       {
211         try
212         {
213           br.close();
214         } catch (IOException e)
215         {
216           // ignore
217         }
218       }
219     }
220     return false;
221   }
222
223   /**
224    * returns a reader to a Fasta response from the Ensembl sequence endpoint
225    * 
226    * @param ids
227    * @return
228    * @throws IOException
229    */
230   protected FileParse getSequenceReader(List<String> ids) throws IOException
231   {
232     URL url = getUrl(ids);
233
234     BufferedReader reader = getHttpResponse(url, ids);
235     if (reader == null)
236     {
237       // request failed
238       return null;
239     }
240     FileParse fp = new FileParse(reader, url.toString(),
241             DataSourceType.URL);
242     return fp;
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
262    * 
263    * @param url
264    * @param ids
265    *          written as Json POST body if more than one
266    * @param readTimeout
267    *          in milliseconds
268    * @return
269    * @throws IOException
270    *           if response code was not 200, or other I/O error
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",
335             getRequestMimeType(multipleIds));
336     connection.setRequestProperty("Accept", getResponseMimeType());
337
338     connection.setUseCaches(false);
339     connection.setDoInput(true);
340     connection.setDoOutput(multipleIds);
341
342     connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
343     connection.setReadTimeout(readTimeout);
344
345     if (multipleIds)
346     {
347       writePostBody(connection, ids);
348     }
349     return connection;
350   }
351
352   /**
353    * Inspects response headers for a 'retry-after' directive, and waits for the
354    * directed period (if less than 10 seconds)
355    * 
356    * @see https://github.com/Ensembl/ensembl-rest/wiki/Rate-Limits
357    * @param connection
358    */
359   void checkRetryAfter(HttpURLConnection connection)
360   {
361     String retryDelay = connection.getHeaderField("Retry-After");
362
363     // to test:
364     // retryDelay = "5";
365
366     if (retryDelay != null)
367     {
368       try
369       {
370         int retrySecs = Integer.valueOf(retryDelay);
371         if (retrySecs > 0 && retrySecs < 10)
372         {
373           System.err
374                   .println("Ensembl REST service rate limit exceeded, waiting "
375                           + retryDelay + " seconds before retrying");
376           Thread.sleep(1000 * retrySecs);
377         }
378       } catch (NumberFormatException | InterruptedException e)
379       {
380         System.err.println("Error handling Retry-After: " + e.getMessage());
381       }
382     }
383   }
384
385   /**
386    * Rechecks if Ensembl is responding, unless the last check was successful and
387    * the retest interval has not yet elapsed. Returns true if Ensembl is up,
388    * else false. Also retrieves and saves the current version of Ensembl data
389    * and REST services at intervals.
390    * 
391    * @return
392    */
393   protected boolean isEnsemblAvailable()
394   {
395     EnsemblInfo info = domainData.get(getDomain());
396
397     long now = System.currentTimeMillis();
398
399     /*
400      * recheck if Ensembl is up if it was down, or the recheck period has elapsed
401      */
402     boolean retestAvailability = (now
403             - info.lastAvailableCheckTime) > AVAILABILITY_RETEST_INTERVAL;
404     if (!info.restAvailable || retestAvailability)
405     {
406       info.restAvailable = checkEnsembl();
407       info.lastAvailableCheckTime = now;
408     }
409
410     /*
411      * refetch Ensembl versions if the recheck period has elapsed
412      */
413     boolean refetchVersion = (now
414             - info.lastVersionCheckTime) > VERSION_RETEST_INTERVAL;
415     if (refetchVersion)
416     {
417       checkEnsemblRestVersion();
418       checkEnsemblDataVersion();
419       info.lastVersionCheckTime = now;
420     }
421
422     return info.restAvailable;
423   }
424
425   /**
426    * Constructs, writes and flushes the POST body of the request, containing the
427    * query ids in JSON format
428    * 
429    * @param connection
430    * @param ids
431    * @throws IOException
432    */
433   protected void writePostBody(HttpURLConnection connection,
434           List<String> ids) throws IOException
435   {
436     boolean first;
437     StringBuilder postBody = new StringBuilder(64);
438     postBody.append("{\"ids\":[");
439     first = true;
440     for (String id : ids)
441     {
442       if (!first)
443       {
444         postBody.append(",");
445       }
446       first = false;
447       postBody.append("\"");
448       postBody.append(id.trim());
449       postBody.append("\"");
450     }
451     postBody.append("]}");
452     byte[] thepostbody = postBody.toString().getBytes();
453     connection.setRequestProperty("Content-Length",
454             Integer.toString(thepostbody.length));
455     DataOutputStream wr = new DataOutputStream(
456             connection.getOutputStream());
457     wr.write(thepostbody);
458     wr.flush();
459     wr.close();
460   }
461
462   /**
463    * Fetches and checks Ensembl's REST version number
464    * 
465    * @return
466    */
467   private void checkEnsemblRestVersion()
468   {
469     EnsemblInfo info = domainData.get(getDomain());
470
471     JSONParser jp = new JSONParser();
472     URL url = null;
473     try
474     {
475       url = new URL(getDomain() + "/info/rest" + CONTENT_TYPE_JSON);
476       BufferedReader br = getHttpResponse(url, null);
477       if (br == null)
478       {
479         return;
480       }
481       JSONObject val = (JSONObject) jp.parse(br);
482       String version = val.get("release").toString();
483       String majorVersion = version.substring(0, version.indexOf("."));
484       String expected = info.expectedRestVersion;
485       String expectedMajorVersion = expected.substring(0,
486               expected.indexOf("."));
487       info.restMajorVersionMismatch = false;
488       try
489       {
490         /*
491          * if actual REST major version is ahead of what we expect,
492          * record this in case we want to warn the user
493          */
494         if (Float.valueOf(majorVersion) > Float
495                 .valueOf(expectedMajorVersion))
496         {
497           info.restMajorVersionMismatch = true;
498         }
499       } catch (NumberFormatException e)
500       {
501         System.err.println("Error in REST version: " + e.toString());
502       }
503
504       /*
505        * check if REST version is later than what Jalview has tested against,
506        * if so warn; we don't worry if it is earlier (this indicates Jalview has
507        * been tested in advance against the next pending REST version)
508        */
509       boolean laterVersion = StringUtils.compareVersions(version,
510               expected) == 1;
511       if (laterVersion)
512       {
513         System.err.println(String.format(
514                 "EnsemblRestClient expected %s REST version %s but found %s, see %s",
515                 getDbSource(), expected, version, REST_CHANGE_LOG));
516       }
517       info.restVersion = version;
518     } catch (Throwable t)
519     {
520       System.err.println(
521               "Error checking Ensembl REST version: " + t.getMessage());
522     }
523   }
524
525   public boolean isRestMajorVersionMismatch()
526   {
527     return domainData.get(getDomain()).restMajorVersionMismatch;
528   }
529
530   /**
531    * Fetches and checks Ensembl's data version number
532    * 
533    * @return
534    */
535   private void checkEnsemblDataVersion()
536   {
537     JSONParser jp = new JSONParser();
538     URL url = null;
539     BufferedReader br = null;
540
541     try
542     {
543       url = new URL(getDomain() + "/info/data" + CONTENT_TYPE_JSON);
544       br = getHttpResponse(url, null);
545       if (br != null)
546       {
547         JSONObject val = (JSONObject) jp.parse(br);
548         JSONArray versions = (JSONArray) val.get("releases");
549         domainData.get(getDomain()).dataVersion = versions.get(0)
550                 .toString();
551       }
552     } catch (Throwable t)
553     {
554       System.err.println(
555               "Error checking Ensembl data version: " + t.getMessage());
556     } finally
557     {
558       if (br != null)
559       {
560         try
561         {
562           br.close();
563         } catch (IOException e)
564         {
565           // ignore
566         }
567       }
568     }
569   }
570
571   public String getEnsemblDataVersion()
572   {
573     return domainData.get(getDomain()).dataVersion;
574   }
575
576   @Override
577   public String getDbVersion()
578   {
579     return getEnsemblDataVersion();
580   }
581
582 }