View Javadoc
1   package fr.ifremer.quadrige3.core.service.http;
2   
3   /*-
4    * #%L
5    * Quadrige3 Core :: Client API
6    * %%
7    * Copyright (C) 2017 - 2019 Ifremer
8    * %%
9    * This program is free software: you can redistribute it and/or modify
10   * it under the terms of the GNU Affero General Public License as published by
11   * the Free Software Foundation, either version 3 of the License, or
12   * (at your option) any later version.
13   *
14   * This program is distributed in the hope that it will be useful,
15   * but WITHOUT ANY WARRANTY; without even the implied warranty of
16   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17   * GNU General Public License for more details.
18   *
19   * You should have received a copy of the GNU Affero General Public License
20   * along with this program.  If not, see <http://www.gnu.org/licenses/>.
21   * #L%
22   */
23  
24  import com.google.gson.Gson;
25  import com.google.gson.reflect.TypeToken;
26  import fr.ifremer.quadrige3.core.ProgressionCoreModel;
27  import fr.ifremer.quadrige3.core.config.QuadrigeConfiguration;
28  import fr.ifremer.quadrige3.core.dao.technical.Assert;
29  import fr.ifremer.quadrige3.core.dao.technical.Files;
30  import fr.ifremer.quadrige3.core.dao.technical.gson.Gsons;
31  import fr.ifremer.quadrige3.core.dao.technical.http.CustomHttpHeaders;
32  import fr.ifremer.quadrige3.core.dao.technical.http.CustomHttpStatus;
33  import fr.ifremer.quadrige3.core.exception.*;
34  import fr.ifremer.quadrige3.core.security.AuthenticationInfo;
35  import org.apache.commons.lang3.builder.ToStringBuilder;
36  import org.apache.commons.lang3.builder.ToStringStyle;
37  import org.apache.commons.logging.Log;
38  import org.apache.commons.logging.LogFactory;
39  import org.apache.http.*;
40  import org.apache.http.auth.AuthScope;
41  import org.apache.http.auth.UsernamePasswordCredentials;
42  import org.apache.http.client.CredentialsProvider;
43  import org.apache.http.client.HttpRequestRetryHandler;
44  import org.apache.http.client.config.RequestConfig;
45  import org.apache.http.client.methods.HttpPost;
46  import org.apache.http.client.methods.HttpUriRequest;
47  import org.apache.http.client.protocol.HttpClientContext;
48  import org.apache.http.client.utils.URIBuilder;
49  import org.apache.http.conn.HttpHostConnectException;
50  import org.apache.http.entity.ContentType;
51  import org.apache.http.entity.mime.MultipartEntityBuilder;
52  import org.apache.http.entity.mime.content.ContentBody;
53  import org.apache.http.entity.mime.content.StringBody;
54  import org.apache.http.impl.client.BasicCredentialsProvider;
55  import org.apache.http.impl.client.CloseableHttpClient;
56  import org.apache.http.impl.client.HttpClients;
57  import org.apache.http.protocol.HTTP;
58  import org.apache.http.util.EntityUtils;
59  import org.nuiton.i18n.I18n;
60  import org.springframework.context.annotation.Lazy;
61  import org.springframework.stereotype.Service;
62  
63  import javax.annotation.Resource;
64  import javax.net.ssl.SSLException;
65  import java.io.*;
66  import java.lang.reflect.Type;
67  import java.net.*;
68  import java.nio.charset.Charset;
69  import java.nio.charset.StandardCharsets;
70  import java.nio.file.Path;
71  import java.util.*;
72  import java.util.function.Function;
73  import java.util.regex.Matcher;
74  import java.util.regex.Pattern;
75  import java.util.stream.Collectors;
76  
77  import static org.nuiton.i18n.I18n.t;
78  
79  /**
80   * HTTP Service used to ease communication with synchronization server
81   *
82   * @author peck7 on 02/04/2019.
83   */
84  @Service("httpService")
85  @Lazy
86  public class HttpServiceImpl implements HttpService, Closeable {
87  
88      private final static Log LOG = LogFactory.getLog(HttpServiceImpl.class);
89  
90      private final static Pattern PATTERN_INTERNAL_SERVER_ERROR_MESSAGE = Pattern.compile("\"\\[([0-9]+)] .*");
91      private static final String PART_UPLOAD_FILE = "file";
92      private static final String PART_UPLOAD_FILENAME = "filename";
93      private static final int MAX_RETRY_COUNT = 3;
94  
95      @Resource
96      private QuadrigeConfiguration configuration;
97  
98      private RequestConfig requestConfig = null;
99  
100     private Gson gson = null;
101 
102     private Map<AuthenticationInfo, CloseableHttpClient> httpClientMap = new HashMap<>();
103 
104     @Override
105     public CloseableHttpClient getHttpClient(AuthenticationInfo authenticationInfo) {
106         return httpClientMap.computeIfAbsent(authenticationInfo, this::newHttpClient);
107     }
108 
109     @Override
110     public Gson getGson() {
111         if (gson == null) {
112             gson = Gsons
113                     .newBuilder(configuration.getDbTimezone())
114                     .create();
115         }
116         return gson;
117     }
118 
119     @Override
120     public URI getPathUri(String... paths) {
121         try {
122             URI baseURI = configuration.getSynchronizationSiteUrl().toURI();
123             String pathToAppend = Arrays.stream(paths).filter(Objects::nonNull).collect(Collectors.joining("/"));
124 
125             URIBuilder builder = new URIBuilder(baseURI);
126             String path = baseURI.getPath() + pathToAppend;
127 
128             // Remove redundant slash
129             if (path.contains("//")) {
130                 path = path.replaceAll("/[/]+", "/");
131             }
132 
133             builder.setPath(path);
134             return builder.build();
135 
136         } catch (URISyntaxException e) {
137             throw new QuadrigeTechnicalException(t("quadrige3.error.remote.synchronizationSiteUrl", e.getMessage()), e);
138         }
139     }
140 
141     @Override
142     public void executeRequest(AuthenticationInfo authenticationInfo, HttpUriRequest request) {
143         executeRequest(authenticationInfo, request, (Class<?>) null);
144     }
145 
146     @Override
147     public <T> T executeRequest(AuthenticationInfo authenticationInfo, HttpUriRequest request, Class<? extends T> resultClass) {
148         return executeRequest(authenticationInfo, request, resultClass, false);
149     }
150 
151     @Override
152     public <T> T executeRequest(AuthenticationInfo authenticationInfo, HttpUriRequest request, Class<? extends T> resultClass, boolean allowNullResult) {
153 
154         // execute request with a class parser
155         return executeRequest(authenticationInfo, request,
156                 response -> resultClass != null
157                         ? parseResponse(response, resultClass, allowNullResult)
158                         : null);
159     }
160 
161     @Override
162     public <T> T executeRequest(AuthenticationInfo authenticationInfo, HttpUriRequest request, Type type) {
163 
164         // execute request with a type parser
165         return executeRequest(authenticationInfo, request,
166                 response -> type != null
167                         ? parseResponse(response, type, false)
168                         : null);
169 
170     }
171 
172     @Override
173     public void executeDownloadFileRequest(AuthenticationInfo authenticationInfo, HttpUriRequest request, ProgressionCoreModel progressionModel, File outputFile) {
174         Assert.notNull(request);
175         Assert.notNull(progressionModel);
176         Assert.notNull(outputFile);
177 
178         boolean errorReturned = false;
179         try {
180 
181             HttpResponse response = getHttpClient(authenticationInfo).execute(request);
182 
183             int statusCode = response.getStatusLine().getStatusCode();
184             if (statusCode != CustomHttpStatus.SC_OK) {
185 
186                 errorReturned = true;
187                 switch (statusCode) {
188 
189                     case CustomHttpStatus.SC_UNAUTHORIZED:
190                     case CustomHttpStatus.SC_FORBIDDEN:
191                         throw new HttpAuthenticationException(I18n.t("quadrige3.error.authenticate.unauthorized"));
192                     case CustomHttpStatus.SC_NOT_FOUND:
193                         throw new HttpNotFoundException(I18n.t("quadrige3.error.notFound"));
194                     case CustomHttpStatus.SC_SERVICE_UNAVAILABLE:
195                         throw new HttpUnavailableException(I18n.t("quadrige3.error.server.unavailable"));
196                     default:
197                         throw new QuadrigeTechnicalException(
198                                 String.format("error while downloading file from [%s]; server responds: %s", request.getURI(), response.getStatusLine().getReasonPhrase()));
199 
200                 }
201 
202             }
203 
204             // initialize progression
205             progressionModel.setTotal(response.getEntity().getContentLength());
206 
207             // prepare output
208             Path output = outputFile.toPath();
209             Files.deleteQuietly(output);
210             java.nio.file.Files.createDirectories(output.getParent());
211 
212             // copy stream
213             try (InputStream inputStream = new BufferedInputStream(response.getEntity().getContent());
214                  OutputStream outputStream = java.nio.file.Files.newOutputStream(output)) {
215                 Files.copyStream(inputStream, outputStream, progressionModel);
216             }
217 
218         } catch (InterruptedIOException e) {
219             throw new QuadrigeTechnicalException(I18n.t("quadrige3.error.timeout"), e);
220         } catch (IOException e) {
221             throw new QuadrigeTechnicalException(I18n.t("quadrige3.error.connect"), e);
222         } finally {
223             if (errorReturned) {
224                 // response error must evict the client to avoid future timeout problems (Mantis #48486, #48447)
225                 evictClient(authenticationInfo);
226             }
227         }
228     }
229 
230     @Override
231     public void executeUploadFileRequest(AuthenticationInfo authenticationInfo, HttpPost request, ProgressionCoreModel progressionModel, File inputFile,
232                                          Map<String, ContentBody> additionalParts) {
233 
234         MultipartEntityBuilder builder = MultipartEntityBuilder.create();
235         // add file
236         builder.addPart(PART_UPLOAD_FILE, new ProgressFileBody(inputFile, progressionModel));
237         // add file name
238         builder.addPart(PART_UPLOAD_FILENAME, new StringBody(inputFile.getName(), ContentType.create("text/plain", Charset.defaultCharset())));
239         // add additional parts
240         if (additionalParts != null) {
241             additionalParts.keySet().forEach(partName -> builder.addPart(partName, additionalParts.get(partName)));
242         }
243 
244         request.setEntity(builder.build());
245         request.addHeader(HTTP.EXPECT_DIRECTIVE, HTTP.EXPECT_CONTINUE);
246 
247         // execute request
248         executeRequest(authenticationInfo, request);
249 
250     }
251 
252     @Override
253     public void close() throws IOException {
254         // close http connections
255         for (CloseableHttpClient closeableHttpClient : httpClientMap.values()) {
256             closeableHttpClient.close();
257         }
258     }
259 
260     private <T> T executeRequest(AuthenticationInfo authenticationInfo, HttpUriRequest request, Function<HttpResponse, T> parser) {
261         T result;
262 
263         if (LOG.isDebugEnabled()) {
264             LOG.debug("Executing request : " + request.getRequestLine());
265         }
266 
267         // Setting user language, from I18n locale
268         if (I18n.getDefaultLocale() != null) {
269             request.setHeader(HttpHeaders.ACCEPT_LANGUAGE, I18n.getDefaultLocale().getLanguage());
270         } else if (LOG.isWarnEnabled()) {
271             LOG.warn(String.format("I18n not initialized properly: no default locale found ! Could not set HTTP header [%s] with user language", HttpHeaders.ACCEPT_LANGUAGE));
272         }
273 
274         boolean errorReturned = false;
275         try {
276             HttpResponse response = getHttpClient(authenticationInfo).execute(request);
277 
278             if (LOG.isDebugEnabled()) {
279                 LOG.debug("Received response : " + response.getStatusLine());
280             }
281 
282             int statusCode = response.getStatusLine().getStatusCode();
283             if (statusCode == CustomHttpStatus.SC_OK) {
284 
285                 // parse response and consume
286                 result = parser.apply(response);
287                 EntityUtils.consume(response.getEntity());
288 
289             } else {
290 
291                 errorReturned = true;
292                 switch (statusCode) {
293 
294                     case CustomHttpStatus.SC_INTERNAL_SERVER_ERROR: {
295                         String errorMessage = parseStringResponse(response);
296                         switch (getInternalServerErrorCode(errorMessage)) {
297                             case CustomHttpStatus.SC_BAD_UPDATE_DT:
298                                 throw new BadUpdateDtException(errorMessage);
299                             case CustomHttpStatus.SC_DATA_LOCKED:
300                                 throw new DataLockedException(errorMessage);
301                             case CustomHttpStatus.SC_DELETE_FORBIDDEN: {
302                                 List<String> objectIds = null;
303                                 // Get specific header for this exception
304                                 Header header = response.getFirstHeader(CustomHttpHeaders.HH_DELETE_FORBIDDEN_OBJECT_IDS);
305                                 if (header != null) {
306                                     objectIds = getGson().fromJson(header.getValue(), new TypeToken<ArrayList<String>>() {
307                                     }.getType());
308                                 }
309                                 throw new DeleteForbiddenException(errorMessage, objectIds);
310                             }
311                             case CustomHttpStatus.SC_SAVE_FORBIDDEN: {
312                                 SaveForbiddenException.Type exceptionType = null;
313                                 List<String> objectIds = null;
314                                 // Get specific header for this exception
315                                 Header header = response.getFirstHeader(CustomHttpHeaders.HH_SAVE_FORBIDDEN_TYPE);
316                                 if (header != null)
317                                     exceptionType = SaveForbiddenException.Type.valueOf(header.getValue().toUpperCase());
318                                 header = response.getFirstHeader(CustomHttpHeaders.HH_SAVE_FORBIDDEN_OBJECT_IDS);
319                                 if (header != null) {
320                                     objectIds = getGson().fromJson(header.getValue(), new TypeToken<ArrayList<String>>() {
321                                     }.getType());
322                                 }
323                                 throw new SaveForbiddenException(exceptionType, errorMessage, objectIds);
324                             }
325                             default:
326                                 throw new QuadrigeTechnicalException(I18n.t("quadrige3.error.server.internal", response.getStatusLine().toString()), new Exception(errorMessage));
327                         }
328                     }
329 
330                     case CustomHttpStatus.SC_UNAUTHORIZED:
331                     case CustomHttpStatus.SC_FORBIDDEN:
332                         throw new HttpAuthenticationException(I18n.t("quadrige3.error.authenticate.unauthorized"));
333                     case CustomHttpStatus.SC_NOT_FOUND:
334                         throw new HttpNotFoundException(I18n.t("quadrige3.error.notFound"));
335                     case CustomHttpStatus.SC_SERVICE_UNAVAILABLE:
336                         throw new HttpUnavailableException(I18n.t("quadrige3.error.server.unavailable"));
337                     default:
338                         throw new QuadrigeTechnicalException(I18n.t("quadrige3.error.request.failed", response.getStatusLine().toString()));
339                 }
340             }
341 
342         } catch (InterruptedIOException e) {
343             throw new QuadrigeTechnicalException(I18n.t("quadrige3.error.timeout"), e);
344         } catch (IOException e) {
345             throw new QuadrigeTechnicalException(I18n.t("quadrige3.error.connect"), e);
346         } finally {
347             if (errorReturned) {
348                 // response error must evict the client to avoid future timeout problems (Mantis #48486, #48447)
349                 evictClient(authenticationInfo);
350             }
351         }
352 
353         return result;
354     }
355 
356     private void evictClient(AuthenticationInfo authenticationInfo) {
357 
358         CloseableHttpClient client = httpClientMap.get(authenticationInfo);
359         if (client != null) {
360             try {
361                 client.close();
362             } catch (IOException ignored) {
363             }
364             httpClientMap.remove(authenticationInfo);
365         }
366     }
367 
368     private CloseableHttpClient newHttpClient(AuthenticationInfo authenticationInfo) {
369 
370         return HttpClients.custom()
371                 .setDefaultRequestConfig(getRequestConfig())
372                 .setDefaultCredentialsProvider(getCredentialsProvider(authenticationInfo))
373                 .setRetryHandler(getRetryHandler())
374                 .evictExpiredConnections()
375                 .build();
376     }
377 
378     private HttpRequestRetryHandler getRetryHandler() {
379         return (exception, executionCount, context) -> {
380             boolean retrying = true;
381             if (exception instanceof NoRouteToHostException) {
382                 // Bad DNS name
383                 retrying = false;
384             } else if (exception instanceof InterruptedIOException) {
385                 // Timeout
386                 retrying = false;
387             } else if (exception instanceof UnknownHostException) {
388                 // Unknown host
389                 retrying = false;
390             } else if (exception instanceof SSLException) {
391                 // SSL handshake exception
392                 retrying = false;
393             } else if (exception instanceof HttpHostConnectException) {
394                 // Host connect error
395                 retrying = false;
396             }
397 
398             if (retrying && executionCount >= MAX_RETRY_COUNT) {
399                 // Do not retry if over max retry count
400                 return false;
401             }
402 
403             HttpClientContext clientContext = HttpClientContext.adapt(context);
404             if (!retrying) {
405                 if (LOG.isWarnEnabled()) {
406                     LOG.warn("Failed request to " + clientContext.getRequest().getRequestLine() + ": " + exception.getMessage());
407                 }
408                 return false;
409             }
410 
411             HttpRequest request = clientContext.getRequest();
412             boolean idempotent = !(request instanceof HttpEntityEnclosingRequest);
413             if (idempotent) {
414                 // Retry if the request is considered idempotent
415                 if (LOG.isWarnEnabled()) LOG.warn("Failed (but will retry) request to " + request.getRequestLine() + ": " + exception.getMessage());
416                 return true;
417             }
418             return false;
419         };
420     }
421 
422     /**
423      * <p>
424      * Getter for the field <code>requestConfig</code>.
425      * </p>
426      *
427      * @return a {@link org.apache.http.client.config.RequestConfig} object.
428      */
429     private RequestConfig getRequestConfig() {
430         if (requestConfig == null) {
431             Integer timeOut = configuration.getSynchronizationSiteTimeout();
432 
433             // build request config for timeout
434             requestConfig = RequestConfig.custom()
435                     .setSocketTimeout(timeOut)
436                     .setConnectTimeout(timeOut)
437                     .setConnectionRequestTimeout(timeOut)
438                     .build();
439         }
440 
441         return requestConfig;
442     }
443 
444     /**
445      * <p>
446      * getCredentialsProvider.
447      * </p>
448      *
449      * @param authenticationInfo a {@link fr.ifremer.quadrige3.core.security.AuthenticationInfo} object.
450      * @return a {@link org.apache.http.client.CredentialsProvider} object.
451      */
452     private CredentialsProvider getCredentialsProvider(AuthenticationInfo authenticationInfo) {
453         // For anonymous connection
454         if (authenticationInfo == null) {
455             return null;
456         }
457 
458         URL url = configuration.getSynchronizationSiteUrl();
459         // build credentials information
460         CredentialsProvider result = new BasicCredentialsProvider();
461         result.setCredentials(new AuthScope(url.getHost(), url.getPort()),
462                 new UsernamePasswordCredentials(authenticationInfo.getLogin(), authenticationInfo.getPassword()));
463         return result;
464     }
465 
466     private String parseStringResponse(HttpResponse response) throws IOException {
467         try (InputStream content = response.getEntity().getContent()) {
468             String stringContent = getContentAsString(content);
469             if (LOG.isDebugEnabled()) {
470                 LOG.debug("Parsing response:\n" + stringContent);
471             }
472             return stringContent;
473         }
474     }
475 
476     private <T> T parseResponse(HttpResponse response, Object objectType, boolean allowEmptyResponse) {
477         T result = null;
478 
479         try (InputStream content = response.getEntity().getContent()) {
480             Reader reader = new InputStreamReader(content, StandardCharsets.UTF_8);
481             if (objectType instanceof Type)
482                 result = getGson().fromJson(reader, (Type) objectType);
483         } catch (IOException e) {
484             throw new QuadrigeTechnicalException(e.getMessage());
485         }
486 
487         if (!allowEmptyResponse && result == null) {
488             throw new QuadrigeTechnicalException("emptyResponse");
489         }
490 
491         if (LOG.isTraceEnabled()) {
492             LOG.trace("response: " + (result != null ? ToStringBuilder.reflectionToString(result, ToStringStyle.SHORT_PREFIX_STYLE) : "null"));
493         }
494 
495         return result;
496     }
497 
498     private String getContentAsString(InputStream content) throws IOException {
499         Reader reader = new InputStreamReader(content, StandardCharsets.UTF_8);
500         StringBuilder result = new StringBuilder();
501         char[] buf = new char[64];
502         int len;
503         while ((len = reader.read(buf)) != -1) {
504             result.append(buf, 0, len);
505         }
506         return result.toString();
507     }
508 
509     private int getInternalServerErrorCode(String message) {
510         if (message != null) {
511             Matcher matcher = PATTERN_INTERNAL_SERVER_ERROR_MESSAGE.matcher(message);
512             if (matcher.matches()) {
513                 String errorCodeAsStr = matcher.group(1);
514                 return Integer.parseInt(errorCodeAsStr);
515             }
516         }
517         // By default, return error 500
518         return CustomHttpStatus.SC_INTERNAL_SERVER_ERROR;
519     }
520 
521 }