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