JAL-2105 check content of Ensembl REST ping response
[jalview.git] / src / jalview / ext / ensembl / EnsemblRestClient.java
1 package jalview.ext.ensembl;
2
3 import jalview.io.FileParse;
4 import jalview.util.StringUtils;
5
6 import java.io.BufferedReader;
7 import java.io.DataOutputStream;
8 import java.io.IOException;
9 import java.io.InputStream;
10 import java.io.InputStreamReader;
11 import java.net.HttpURLConnection;
12 import java.net.MalformedURLException;
13 import java.net.URL;
14 import java.util.HashMap;
15 import java.util.List;
16 import java.util.Map;
17
18 import javax.ws.rs.HttpMethod;
19
20 import org.json.simple.JSONArray;
21 import org.json.simple.JSONObject;
22 import org.json.simple.parser.JSONParser;
23
24 import com.stevesoft.pat.Regex;
25
26 /**
27  * Base class for Ensembl REST service clients
28  * 
29  * @author gmcarstairs
30  */
31 abstract class EnsemblRestClient extends EnsemblSequenceFetcher
32 {
33   /*
34    * update these constants when Jalview has been checked / updated for
35    * changes to Ensembl REST API
36    * @see https://github.com/Ensembl/ensembl-rest/wiki/Change-log
37    */
38   private static final String LATEST_ENSEMBLGENOMES_REST_VERSION = "4.4";
39
40   private static final String LATEST_ENSEMBL_REST_VERSION = "4.5";
41
42   private static Map<String, EnsemblInfo> domainData;
43
44   // @see https://github.com/Ensembl/ensembl-rest/wiki/Output-formats
45   private static final String PING_URL = "http://rest.ensembl.org/info/ping.json";
46
47   private final static long AVAILABILITY_RETEST_INTERVAL = 10000L; // 10 seconds
48
49   private final static long VERSION_RETEST_INTERVAL = 1000L * 3600; // 1 hr
50
51   private static final Regex TRANSCRIPT_REGEX = new Regex(
52             "(ENS)([A-Z]{3}|)T[0-9]{11}$");
53
54   private static final Regex GENE_REGEX = new Regex(
55             "(ENS)([A-Z]{3}|)G[0-9]{11}$");
56
57   static
58   {
59     domainData = new HashMap<String, EnsemblInfo>();
60     domainData.put(ENSEMBL_REST, new EnsemblInfo(ENSEMBL_REST,
61             LATEST_ENSEMBL_REST_VERSION));
62     domainData.put(ENSEMBL_GENOMES_REST, new EnsemblInfo(
63             ENSEMBL_GENOMES_REST, LATEST_ENSEMBLGENOMES_REST_VERSION));
64   }
65
66   protected volatile boolean inProgress = false;
67
68   /**
69    * Default constructor to use rest.ensembl.org
70    */
71   public EnsemblRestClient()
72   {
73     this(ENSEMBL_REST);
74   }
75
76   /**
77    * Constructor given the target domain to fetch data from
78    * 
79    * @param d
80    */
81   public EnsemblRestClient(String d)
82   {
83     setDomain(d);
84   }
85
86   /**
87    * Answers true if the query matches the regular expression pattern for an
88    * Ensembl transcript stable identifier
89    * 
90    * @param query
91    * @return
92    */
93   public boolean isTranscriptIdentifier(String query)
94   {
95     return query == null ? false : TRANSCRIPT_REGEX.search(query);
96   }
97
98   /**
99    * Answers true if the query matches the regular expression pattern for an
100    * Ensembl gene stable identifier
101    * 
102    * @param query
103    * @return
104    */
105   public boolean isGeneIdentifier(String query)
106   {
107     return query == null ? false : GENE_REGEX.search(query);
108   }
109
110   @Override
111   public boolean queryInProgress()
112   {
113     return inProgress;
114   }
115
116   @Override
117   public StringBuffer getRawRecords()
118   {
119     return null;
120   }
121
122   /**
123    * Returns the URL for the client http request
124    * 
125    * @param ids
126    * @return
127    * @throws MalformedURLException
128    */
129   protected abstract URL getUrl(List<String> ids)
130           throws MalformedURLException;
131
132   /**
133    * Returns true if client uses GET method, false if it uses POST
134    * 
135    * @return
136    */
137   protected abstract boolean useGetRequest();
138
139   /**
140    * Return the desired value for the Content-Type request header
141    * 
142    * @param multipleIds
143    * 
144    * @return
145    * @see https://github.com/Ensembl/ensembl-rest/wiki/HTTP-Headers
146    */
147   protected abstract String getRequestMimeType(boolean multipleIds);
148
149   /**
150    * Return the desired value for the Accept request header
151    * 
152    * @return
153    * @see https://github.com/Ensembl/ensembl-rest/wiki/HTTP-Headers
154    */
155   protected abstract String getResponseMimeType();
156
157   /**
158    * Checks Ensembl's REST 'ping' endpoint, and returns true if response
159    * indicates available, else false
160    * 
161    * @see http://rest.ensembl.org/documentation/info/ping
162    * @return
163    */
164   private boolean checkEnsembl()
165   {
166     HttpURLConnection conn = null;
167     try
168     {
169       // note this format works for both ensembl and ensemblgenomes
170       // info/ping.json works for ensembl only (March 2016)
171       URL ping = new URL(getDomain()
172               + "/info/ping?content-type=application/json");
173
174       /*
175        * expect {"ping":1} if ok
176        */
177       BufferedReader br = getHttpResponse(ping, null);
178       JSONParser jp = new JSONParser();
179       JSONObject val = (JSONObject) jp.parse(br);
180       String pingString = val.get("ping").toString();
181       return pingString != null;
182     } catch (Throwable t)
183     {
184       System.err.println("Error connecting to " + PING_URL + ": "
185               + t.getMessage());
186     } finally
187     {
188       if (conn != null)
189       {
190         conn.disconnect();
191       }
192     }
193     return false;
194   }
195
196   /**
197    * returns a reader to a Fasta response from the Ensembl sequence endpoint
198    * 
199    * @param ids
200    * @return
201    * @throws IOException
202    */
203   protected FileParse getSequenceReader(List<String> ids)
204           throws IOException
205   {
206     URL url = getUrl(ids);
207   
208     BufferedReader reader = getHttpResponse(url, ids);
209     FileParse fp = new FileParse(reader, url.toString(), "HTTP_POST");
210     return fp;
211   }
212
213   /**
214    * Writes the HTTP request and gets the response as a reader.
215    * 
216    * @param url
217    * @param ids
218    *          written as Json POST body if more than one
219    * @return
220    * @throws IOException
221    *           if response code was not 200, or other I/O error
222    */
223   protected BufferedReader getHttpResponse(URL url, List<String> ids)
224           throws IOException
225   {
226     // long now = System.currentTimeMillis();
227     HttpURLConnection connection = (HttpURLConnection) url.openConnection();
228   
229     /*
230      * POST method allows multiple queries in one request; it is supported for
231      * sequence queries, but not for overlap
232      */
233     boolean multipleIds = ids != null && ids.size() > 1;
234     connection.setRequestMethod(multipleIds ? HttpMethod.POST
235             : HttpMethod.GET);
236     connection.setRequestProperty("Content-Type",
237             getRequestMimeType(multipleIds));
238     connection.setRequestProperty("Accept", getResponseMimeType());
239
240     connection.setUseCaches(false);
241     connection.setDoInput(true);
242     connection.setDoOutput(multipleIds);
243
244     if (multipleIds)
245     {
246       writePostBody(connection, ids);
247     }
248   
249     InputStream response = connection.getInputStream();
250     int responseCode = connection.getResponseCode();
251   
252     if (responseCode != 200)
253     {
254       /*
255        * note: a GET request for an invalid id returns an error code e.g. 415
256        * but POST request returns 200 and an empty Fasta response 
257        */
258       throw new IOException(
259               "Response code was not 200. Detected response was "
260                       + responseCode);
261     }
262     // System.out.println(getClass().getName() + " took "
263     // + (System.currentTimeMillis() - now) + "ms to fetch");
264
265     checkRateLimits(connection);
266   
267     BufferedReader reader = null;
268     reader = new BufferedReader(new InputStreamReader(response, "UTF-8"));
269     return reader;
270   }
271
272   /**
273    * Inspect response headers for any sign of server overload and respect any
274    * 'retry-after' directive
275    * 
276    * @see https://github.com/Ensembl/ensembl-rest/wiki/Rate-Limits
277    * @param connection
278    */
279   void checkRateLimits(HttpURLConnection connection)
280   {
281     // number of requests allowed per time interval:
282     String limit = connection.getHeaderField("X-RateLimit-Limit");
283     // length of quota time interval in seconds:
284     // String period = connection.getHeaderField("X-RateLimit-Period");
285     // seconds remaining until usage quota is reset:
286     String reset = connection.getHeaderField("X-RateLimit-Reset");
287     // number of requests remaining from quota for current period:
288     String remaining = connection.getHeaderField("X-RateLimit-Remaining");
289     // number of seconds to wait before retrying (if remaining == 0)
290     String retryDelay = connection.getHeaderField("Retry-After");
291
292     // to test:
293     // retryDelay = "5";
294
295     EnsemblInfo info = domainData.get(getDomain());
296     if (retryDelay != null)
297     {
298       System.err.println("Ensembl REST service rate limit exceeded, wait "
299               + retryDelay + " seconds before retrying");
300       try
301       {
302         info.retryAfter = System.currentTimeMillis()
303                 + (1000 * Integer.valueOf(retryDelay));
304       } catch (NumberFormatException e)
305       {
306         System.err.println("Unexpected value for Retry-After: "
307                 + retryDelay);
308       }
309     }
310     else
311     {
312       info.retryAfter = 0;
313       // debug:
314       // System.out.println(String.format(
315       // "%s Ensembl requests remaining of %s (reset in %ss)",
316       // remaining, limit, reset));
317     }
318   }
319   
320   /**
321    * Rechecks if Ensembl is responding, unless the last check was successful and
322    * the retest interval has not yet elapsed. Returns true if Ensembl is up,
323    * else false. Also retrieves and saves the current version of Ensembl data
324    * and REST services at intervals.
325    * 
326    * @return
327    */
328   protected boolean isEnsemblAvailable()
329   {
330     EnsemblInfo info = domainData.get(getDomain());
331
332     long now = System.currentTimeMillis();
333
334     /*
335      * check if we are waiting for 'Retry-After' to expire
336      */
337     if (info.retryAfter > now)
338     {
339       System.err.println("Still " + (1 + (info.retryAfter - now) / 1000)
340               + " secs to wait before retrying Ensembl");
341       return false;
342     }
343     else
344     {
345       info.retryAfter = 0;
346     }
347
348     /*
349      * recheck if Ensembl is up if it was down, or the recheck period has elapsed
350      */
351     boolean retestAvailability = (now - info.lastAvailableCheckTime) > AVAILABILITY_RETEST_INTERVAL;
352     if (!info.restAvailable || retestAvailability)
353     {
354       info.restAvailable = checkEnsembl();
355       info.lastAvailableCheckTime = now;
356     }
357
358     /*
359      * refetch Ensembl versions if the recheck period has elapsed
360      */
361     boolean refetchVersion = (now - info.lastVersionCheckTime) > VERSION_RETEST_INTERVAL;
362     if (refetchVersion)
363     {
364       checkEnsemblRestVersion();
365       checkEnsemblDataVersion();
366       info.lastVersionCheckTime = now;
367     }
368
369     return info.restAvailable;
370   }
371
372   /**
373    * Constructs, writes and flushes the POST body of the request, containing the
374    * query ids in JSON format
375    * 
376    * @param connection
377    * @param ids
378    * @throws IOException
379    */
380   protected void writePostBody(HttpURLConnection connection,
381           List<String> ids) throws IOException
382   {
383     boolean first;
384     StringBuilder postBody = new StringBuilder(64);
385     postBody.append("{\"ids\":[");
386     first = true;
387     for (String id : ids)
388     {
389       if (!first)
390       {
391         postBody.append(",");
392       }
393       first = false;
394       postBody.append("\"");
395       postBody.append(id.trim());
396       postBody.append("\"");
397     }
398     postBody.append("]}");
399     byte[] thepostbody = postBody.toString().getBytes();
400     connection.setRequestProperty("Content-Length",
401             Integer.toString(thepostbody.length));
402     DataOutputStream wr = new DataOutputStream(connection.getOutputStream());
403     wr.write(thepostbody);
404     wr.flush();
405     wr.close();
406   }
407
408   /**
409    * Fetches and checks Ensembl's REST version number
410    * 
411    * @return
412    */
413   private void checkEnsemblRestVersion()
414   {
415     EnsemblInfo info = domainData.get(getDomain());
416
417     JSONParser jp = new JSONParser();
418     URL url = null;
419     try
420     {
421       url = new URL(getDomain()
422               + "/info/rest?content-type=application/json");
423       BufferedReader br = getHttpResponse(url, null);
424       JSONObject val = (JSONObject) jp.parse(br);
425       String version = val.get("release").toString();
426       String majorVersion = version.substring(0, version.indexOf("."));
427       String expected = info.expectedRestVersion;
428       String expectedMajorVersion = expected.substring(0,
429               expected.indexOf("."));
430       info.restMajorVersionMismatch = false;
431       try
432       {
433         /*
434          * if actual REST major version is ahead of what we expect,
435          * record this in case we want to warn the user
436          */
437         if (Float.valueOf(majorVersion) > Float
438                 .valueOf(expectedMajorVersion))
439         {
440           info.restMajorVersionMismatch = true;
441         }
442       } catch (NumberFormatException e)
443       {
444         System.err.println("Error in REST version: " + e.toString());
445       }
446
447       /*
448        * check if REST version is later than what Jalview has tested against,
449        * if so warn; we don't worry if it is earlier (this indicates Jalview has
450        * been tested in advance against the next pending REST version)
451        */
452       boolean laterVersion = StringUtils.compareVersions(version, expected) == 1;
453       if (laterVersion)
454       {
455         System.err.println(String.format(
456                 "Expected %s REST version %s but found %s", getDbSource(),
457                 expected,
458                 version));
459       }
460       info.restVersion = version;
461     } catch (Throwable t)
462     {
463       System.err.println("Error checking Ensembl REST version: "
464               + t.getMessage());
465     }
466   }
467
468   public boolean isRestMajorVersionMismatch()
469   {
470     return domainData.get(getDomain()).restMajorVersionMismatch;
471   }
472
473   /**
474    * Fetches and checks Ensembl's data version number
475    * 
476    * @return
477    */
478   private void checkEnsemblDataVersion()
479   {
480     JSONParser jp = new JSONParser();
481     URL url = null;
482     try
483     {
484       url = new URL(getDomain()
485               + "/info/data?content-type=application/json");
486       BufferedReader br = getHttpResponse(url, null);
487       JSONObject val = (JSONObject) jp.parse(br);
488       JSONArray versions = (JSONArray) val.get("releases");
489       domainData.get(getDomain()).dataVersion = versions.get(0).toString();
490     } catch (Throwable t)
491     {
492       System.err.println("Error checking Ensembl data version: "
493               + t.getMessage());
494     }
495   }
496
497   public String getEnsemblDataVersion()
498   {
499     return domainData.get(getDomain()).dataVersion;
500   }
501
502   @Override
503   public String getDbVersion()
504   {
505     return getEnsemblDataVersion();
506   }
507
508 }